skip to content

Observable Data Services

An observable data service keeps state in a private BehaviorSubject, exposes asObservable() and caches HTTP calls with shareReplay. Interviewers ask about refCount and when signals replace it.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In an Angular observable data service, why keep the BehaviorSubject private and expose state through asObservable() instead?

level: juniorimportance: must knowfreq 66%

answer

  1. who is allowed to push
  2. one write path
  3. read-only view of a Subject
  4. no next() on the handle

basics

~20 s

A private BehaviorSubject means only the service's own methods can emit new state, so every change goes through one path; asObservable() gives consumers an Observable backed by the subject that has no next(), error() or complete() to call.

solid answer

~40 s

The service holds `private readonly state = new BehaviorSubject<Flags>(initial)` and exposes `readonly flags$ = this.state.asObservable()`. A `BehaviorSubject` is chosen because it needs a starting value and hands the current value to every new subscriber, so a component created late still renders the present state at once. `asObservable()` returns a new `Observable` whose source is the subject: consumers can subscribe but cannot call `next()`, `error()` or `complete()`. If the subject itself were public, any component could push unvalidated state or complete the stream and silently break every other subscriber. All writes go through named methods such as `setFlag()`, which build a new state object and call `next()`, so the service stays the single owner of its data.

code

ts · 18 lines
ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, map } from 'rxjs';

export interface Flags { [name: string]: boolean }

@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
  private readonly state = new BehaviorSubject<Flags>({});
  readonly flags$ = this.state.asObservable();

  isOn$(name: string) {
    return this.flags$.pipe(map(flags => flags[name] ?? false));
  }

  setFlag(name: string, on: boolean): void {
    this.state.next({ ...this.state.getValue(), [name]: on });
  }
}

go deeper

for a junior

Know the three parts: a private BehaviorSubject, a public stream from asObservable(), and methods that emit new state. Be able to say why BehaviorSubject gives late subscribers the current value.

for a middle

Explain that asObservable() wraps the subject without copying values, and why every update should emit a new object rather than mutate the current one.

for a senior

Show how a public subject turns debugging into a codebase-wide search, and how in-place mutation hides changes from OnPush children now that OnPush is the default.

for a principal

Frame the private subject as an ownership rule: one writer per piece of state, whether it is written with RxJS or signals, and how that rule survives team growth.

## The pattern in one picture An **observable data service** is an Angular service that owns a piece of application state and publishes it as a stream. Components inject the service, subscribe to the stream (usually through the `async` pipe or `toSignal()`), and ask the service to change the state by calling its methods. The classic shape has three parts: - a **private** `BehaviorSubject` that holds the current state, - a **public** read-only stream created with `asObservable()`, - a set of **public methods** that compute the next state and emit it. ```ts import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; export interface Flags { [name: string]: boolean } @Injectable({ providedIn: 'root' }) export class FeatureFlagService { private readonly state = new BehaviorSubject<Flags>({}); readonly flags$ = this.state.asObservable(); setFlag(name: string, on: boolean): void { this.state.next({ ...this.state.getValue(), [name]: on }); } } ``` ## Why a BehaviorSubject and not a plain Subject A `Subject` in RxJS is both an Observable and an Observer: you can subscribe to it and you can push values into it. A plain `Subject` only delivers values emitted **after** a subscriber arrives. A `BehaviorSubject`: 1. requires an **initial value** in its constructor, 2. remembers the **latest** value, 3. emits that value **synchronously** to every new subscriber, 4. exposes it for synchronous reads through `getValue()` (or the `value` getter). That is exactly what state needs. A component that is created after the flags were set, for example on a lazily loaded route, still receives the current flags the moment it subscribes. With a plain `Subject` it would render nothing until the next change. ## What asObservable() actually does `asObservable()` is a method on RxJS's `Subject` class. In RxJS 7.8 it creates a new `Observable` and sets that Observable's source to the subject. Subscribing to the returned Observable subscribes to the subject, so consumers see every emission, including the `BehaviorSubject`'s replay of the current value. What they do **not** get is the Observer side: the returned object has no `next()`, `error()` or `complete()` methods, so neither the TypeScript compiler nor the runtime lets a consumer push into it. It does **not** copy values. The objects that flow through the stream are the same references the service emitted, which is why the service should treat its state as immutable. ## What goes wrong when the subject is public | Exposed member | What a consumer can do | Consequence | |---|---|---| | `flags$ = this.state.asObservable()` | subscribe only | the service stays the single writer | | `state` (the subject itself) | `next()`, `error()`, `complete()` | any component can inject bad state or end the stream | - A component calling `complete()` ends the stream for **every** subscriber; later subscribers get only the completion. - A component calling `next()` bypasses validation and logic the service would apply. - Debugging becomes a search through every file that injects the service, instead of one class. The TypeScript `private` keyword is only a compile-time check, so `asObservable()` is the part that also holds at runtime. ## Writing state correctly The service's methods should build a **new** state value and emit it: - read the current state with `getValue()` inside the service, - create a new object or array (spread, `map`, `filter`), - call `next()` with it. Mutating the object returned by `getValue()` and never calling `next()` changes data behind the subscribers' backs: nothing is emitted, so the `async` pipe never marks the view, and child components receiving the object through inputs see the same reference. Since v22, components are `OnPush` by default, so a silent mutation that a zone-based `Eager` app sometimes masked on its next check now more often shows up as a screen that does not update. ## Where it fits today The private-subject pattern is still common in production Angular code and still asked about. In new code the same encapsulation is often written with a private `signal()` and `asReadonly()`; the reasoning (one writer, read-only consumers, immutable updates) is identical. ## Side benefits of a single write path Keeping the subject private pays off beyond safety: - **Testing.** A unit test can call `setFlag()` and assert on what `flags$` emits, without faking a subject inside a component. - **Logging and validation.** One method is the only place a change can come from, so a `console.debug` or a guard clause there sees every change. - **Derived streams.** The service can publish computed views such as `isOn$('beta')` built with `map` and `distinctUntilChanged()`, and consumers never need to know how the raw state is shaped. - **Refactoring.** Because consumers see only a stream and methods, the storage can later move to a signal or a store library without touching them. In an interview, the short version is: the subject is the service's private write handle, `asObservable()` is the public read handle, and methods are the only doors between them.

  • Is marking the field private in TypeScript enough on its own?
    Not fully. `private` is erased at compile time, so plain JavaScript or a cast can still reach the subject. Exposing only the result of `asObservable()` means the object consumers hold has no `next()` at runtime either. You can also use a `#`-prefixed field, which JavaScript enforces, but the public API should still be the read-only stream.
  • Why does the service call next() with a spread copy instead of mutating getValue()?
    Mutating the current object emits nothing, so subscribers are never told. Even if you then call `next()` with the same object, consumers that compare references, such as `OnPush` children receiving it as an input or a `distinctUntilChanged()` in a pipeline, see no change. A new object makes each change a distinct, observable event.

saying these in an interview costs you the question

  • Exposing the Subject directly is fine because components only subscribe to it
  • asObservable() hands consumers a safe copy of the state they can mutate
  • A plain Subject works just as well because late subscribers get the last value
  • Marking the field private in TypeScript stops next() calls at runtime
  • Mutating getValue() in place is enough; subscribers will notice the change
open as a page

How do you make an Angular service load a feature-flag config through HttpClient once and share the cached response with every component?

level: middleimportance: must knowfreq 62%

basics

~20 s

Store http.get(url).pipe(shareReplay(1)) in a service field so every caller gets the same shared Observable: the first subscriber fires one request, later subscribers receive the replayed response, and a failed request is retried by the next subscriber.

open as a page

In current Angular, how would you rewrite a BehaviorSubject-based data service with signals, and what changes for the components that consume it?

level: middleimportance: should knowfreq 55%

basics

~10 s

Hold state in a private signal(), expose it with asReadonly(), derive values with computed(), and write through set()/update() with new references; consumers read service.flags() directly, with no subscription, async pipe or teardown.

open as a page

An Angular service polls feature flags with timer, switchMap and shareReplay(1), and polling continues after every subscribing component is destroyed; why, and what does refCount: true change?

level: seniorimportance: should knowfreq 46%

basics

~20 s

shareReplay's refCount defaults to false, so its internal ReplaySubject stays subscribed to the polling source after the last subscriber leaves; shareReplay({ bufferSize: 1, refCount: true }) unsubscribes the source at zero subscribers and restarts it on the next subscription.

open as a page

When does an Angular app outgrow BehaviorSubject or signal-based data services, and how would you decide whether a store library such as NgRx is warranted?

level: principalimportance: should knowfreq 38%

basics

~20 s

Services suffice while each slice of state has one owning service and changes stay traceable; a store library pays off when many features write shared state and changes and side effects need one auditable home.

open as a page