In an Angular observable data service, why keep the BehaviorSubject private and expose state through asObservable() instead?
answer
- who is allowed to push
- one write path
- read-only view of a Subject
- no next() on the handle
basics
~20 sA 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 sThe 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 linesimport { 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
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.
Explain that asObservable() wraps the subject without copying values, and why every update should emit a new object rather than mutate the current one.
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.
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