Back to the path
Level 3 intermediate 25 min #observer#behavioral-pattern#pub-sub#event-driven#mvc#reactive#solid#design-patterns

The Observer Pattern: One-to-Many Change Propagation

Master Observer — the publish/subscribe backbone of event systems, MVC, and reactive UIs. Push vs pull notification, the unsubscribe memory-leak trap (with a real C++ weak_ptr pruning implementation), and where it shades into Mediator, Pub/Sub, and reactive streams.

Intent

Define a one-to-many dependency between objects so that when one object (the subject) changes state, all its dependents (the observers) are notified and updated automatically — without the subject knowing their concrete types.

That single sentence is worth memorizing verbatim. In an interview, lead with it before you draw anything: it signals you know why the pattern exists, not just its shape.

The Problem

You have an object whose state matters to several others. The naive approach hard-wires the dependents into it:

// ANTI-PATTERN: the subject knows every consumer concretely
class Order {
private status: string = "PENDING";

constructor(
  private emailService: EmailService,
  private inventoryService: InventoryService,
  private analytics: AnalyticsClient,
) {}

ship(): void {
  this.status = "SHIPPED";
  // Every new consumer = edit this method + the constructor.
  this.emailService.sendShippedEmail(this);
  this.inventoryService.decrement(this);
  this.analytics.track("order_shipped", this);
}
}

Three things are wrong here, and you should be able to name them:

  1. Open/Closed violation. Adding a fraud-check listener means editing Order — a class that has nothing to do with fraud.
  2. Tight coupling. Order depends on the concrete EmailService, InventoryService, etc. It cannot be unit-tested without all of them.
  3. Wrong direction of control. The thing that changed shouldn’t be responsible for orchestrating everyone who cares. That’s a single-responsibility smell.

Observer inverts this: consumers register interest with the subject, and the subject broadcasts to an abstract list it knows nothing about.

Structure

        +------------------------+              +----------------------+
        |       <<interface>>    |   observers  |     <<interface>>    |
        |        Subject         |<>----------->|       Observer       |
        +------------------------+      *       +----------------------+
        | + attach(o: Observer)  |              | + update(s: Subject) |
        | + detach(o: Observer)  |              +----------------------+
        | + notify()             |                        ^
        +------------------------+                        |
                   ^                          +-----------+-----------+
                   |                          |                       |
        +----------------------+   +----------------------+  +----------------------+
        |   ConcreteSubject    |   |  EmailObserver       |  | InventoryObserver    |
        +----------------------+   +----------------------+  +----------------------+
        | - state              |   | + update(s)          |  | + update(s)          |
        | - observers: List    |   +----------------------+  +----------------------+
        | + getState()         |
        | + setState()         |--- on change, calls notify() which loops
        +----------------------+    observers and calls update() on each

The diamond (<>) is aggregation: the subject holds observers but does not own their lifecycle. That detail is the seed of the memory-leak pitfall we hit later — and, as you’ll see, the C++ implementation has to make an explicit choice (strong vs weak references) to honor it.

Participants

ParticipantResponsibility
Subject (interface)Maintains the observer list; exposes attach/detach/notify. Knows observers only through the abstract Observer type.
Observer (interface)Defines the update() callback the subject invokes.
ConcreteSubjectHolds the real state; calls notify() after any state change worth broadcasting.
ConcreteObserverImplements update(); reacts to the change (and, in pull, queries the subject for what it needs).

Push vs Pull — the design decision interviewers probe

When the subject notifies, how much does it hand over?

  • Push model: the subject sends the changed data as arguments — update(orderId, "SHIPPED"). Observers get exactly what they need with no extra call, but the signature must satisfy every observer, so it tends to over-deliver or bloat.
  • Pull model: the subject sends only a reference to itself — update(this) — and each observer pulls the bits it cares about. Maximally decoupled from the data shape, but observers must know the subject’s interface and may make redundant queries.

Rule of thumb: push a small, stable event payload (an immutable value object) and let that be the contract. This gets you push-like ergonomics without coupling observers to the subject’s internals — the sweet spot most production event systems land on.

Key idea

Push vs pull is not “which is correct” — it’s a coupling-vs-convenience trade. Push couples observers to the payload shape; pull couples them to the subject’s interface. State the trade-off out loud and pick based on how many observer types you expect and how volatile the data is.

A concrete example: an order-event bus

Forget the textbook weather-station. Here’s a realistic slice: an Order aggregate that emits typed events, with observers for notifications, inventory, and analytics. We push an immutable event (best-practice middle ground) and we make unsubscription explicit. Note all three languages stamp the event with a real wall-clock time in epoch milliseconds — TS Date.now(), C++ duration_cast<milliseconds>(...), and Python time.time_ns() // 1_000_000 (since bare time.time() returns fractional seconds, not millis) — so the at payloads are numerically equivalent across the three.

type OrderEvent = {
readonly orderId: string;
readonly status: "PLACED" | "SHIPPED" | "CANCELLED";
readonly at: number;
};

interface OrderObserver {
onOrderChanged(e: OrderEvent): void;
}

class Order {
private observers = new Set<OrderObserver>();
private status: OrderEvent["status"] = "PLACED";
constructor(public readonly orderId: string) {}

subscribe(o: OrderObserver): () => void {
  this.observers.add(o);
  return () => this.observers.delete(o); // unsubscribe handle
}

ship(): void {
  if (this.status !== "PLACED") throw new Error("illegal transition");
  this.status = "SHIPPED";
  this.notify();
}

private notify(): void {
  const e: OrderEvent = { orderId: this.orderId, status: this.status, at: Date.now() };
  // Snapshot so an observer unsubscribing mid-loop can't corrupt iteration.
  for (const o of [...this.observers]) {
    try { o.onOrderChanged(e); }
    catch (err) { console.error("observer failed", err); } // isolate failures
  }
}
}

class EmailObserver implements OrderObserver {
onOrderChanged(e: OrderEvent): void {
  if (e.status === "SHIPPED") console.log(`Email: order ${e.orderId} shipped`);
}
}

Three production-grade details in that code that score points in an interview:

  • Immutable event payload — observers can’t mutate shared state or each other. In TS this is readonly fields, in Python a frozen dataclass, and in C++ the members are plain values (so the event stays copyable/movable for queues and relaying) with immutability enforced by passing const OrderEvent&.
  • Snapshot-before-iterate — an observer that unsubscribes (or subscribes) inside update() won’t corrupt the loop or throw a concurrent-modification error.
  • Failure isolation — one throwing observer doesn’t abort delivery to the rest. (Mention this; most candidates forget it.)
Tip

The C++ here deliberately stores std::weak_ptr, so it demonstrates the non-owning strategy the prose recommends rather than just naming it. The lock() + prune loop in notify() is the canonical weak-reference pruning pattern — exactly what a senior reviewer expects to see. (Note: a std::unordered_set<shared_ptr> would hash by pointer identity, which is fine; we use a vector<weak_ptr> here because weak_ptr isn’t directly hashable and we want the prune-on-notify sweep. Similarly, the Python set relies on observers’ default identity hashing — safe unless you give observers an eq-generating dataclass, which could collide.)

The memory-leak pitfall — the #1 thing seniors check for

Observer’s dirty secret: a live subject holding a strong reference to an observer keeps that observer alive. If observers register but never detach, they leak — and they keep receiving events for state they no longer care about. This is the classic “lapsed listener” leak that plagues long-lived subjects (a global event bus, a cache, a UI store).

Common pitfall

Forgetting to unsubscribe is the most common Observer bug in real systems. A view subscribes to a store, the view is destroyed, but the store still holds it — the GC can never collect it, and worse, its update() fires on a dead view. Always return an unsubscribe handle at subscribe() time and call it in the consumer’s teardown (ngOnDestroy, useEffect cleanup, dispose(), destructor).

Strategies, in rough order of preference:

  1. Explicit unsubscribe handle (shown in the TS/Python above): subscribe() returns a closure that detaches. Hard to forget when teardown is co-located with setup.
  2. Weak references to observers (C++ std::weak_ptr — shown in the C++ above —, Java WeakReference, JS WeakRef/WeakMap). The subject doesn’t keep observers alive; dead ones are pruned (see the lock()/erase sweep in notify()). Use when you genuinely can’t control observer lifecycle — but weak refs make delivery non-deterministic (an observer can vanish between two notifications with no warning), so prefer explicit unsubscription when you can.
  3. Scoped/auto-disposing subscriptions — RAII in C++, using/Disposable patterns, framework lifecycle hooks.

Note on the C++ trade-off: storing std::shared_ptr instead would make the subject a co-owner and re-introduce the exact leak above unless unsubscribe() is reliably called. That’s why the sample uses weak_ptr — it makes the “subject does not own observers” claim true in code, not just in a comment.

Watch out

In a multithreaded subject, notify() iterating the observer list while another thread attach/detaches is a data race. Guard the list with a mutex, and — critically — snapshot the list under the lock, then release it before invoking callbacks. Holding the lock across update() invites deadlock if an observer re-enters the subject. One subtle, interview-grade point: the snapshot of strong references (or the live vector of locked shared_ptrs in the C++ above) also keeps those observers alive for the duration of the callback even if another thread unsubscribes mid-delivery — so an already-snapshotted observer still gets this event. That ties the threading and lifecycle stories together: snapshotting buys both iteration safety and a well-defined “you’ll receive the event you were enrolled for” guarantee.

When to use it

  • Event systems / domain events: “when X happens, an open-ended set of things should react.” Order placed, user signed up, file changed.
  • MVC / MVVM: the Model is the subject; Views observe it. Change the model once, every view refreshes. This is the pattern’s historical home (Smalltalk-80).
  • Reactive UIs and state stores: Redux/Zustand/RxJS Subject, Vue reactivity, signals — all are Observer at the core (a value holder notifying dependents on change).
  • Cache invalidation, configuration hot-reload, progress reporting.

When NOT to use it (over-use & pitfalls)

  • Cascade / “notification storms.” Observer A’s update() mutates B, which notifies C, which mutates A… You get re-entrancy, infinite loops, or O(n²) update floods. If you have a web of mutual dependencies rather than a clean one-to-many, reach for Mediator instead.
  • Order-dependent observers. If observer B only works correctly after observer A has run, you’ve smuggled hidden coupling through an unordered list. Observer guarantees no ordering; if you need a pipeline, model it as one explicitly.
  • You need the result back. Observer is fire-and-forget broadcast. If the subject needs return values or must coordinate responses, it’s the wrong tool.
  • Debuggability cost. Control flow becomes implicit — “who fired this update?” is hard to trace. For a single known consumer, a plain method call is clearer than a subscription.

Relationship to SOLID

  • Open/Closed: the headline win. New reactions = new observers, zero edits to the subject. This is the canonical OCP demonstration.
  • Single Responsibility: the subject’s job is to manage state and announce changes — not to know what shipping an order means to email, inventory, or analytics.
  • Dependency Inversion: the subject depends on the abstract Observer, not concretions. The arrows point at the interface.

Sibling patterns it’s confused with

PatternHow it differs from Observer
Pub/Sub (message bus)Adds a broker between publisher and subscriber. In Observer the subject holds direct references to observers; in Pub/Sub they’re fully decoupled through a channel/topic and often async/cross-process. Observer is in-process and synchronous by default.
MediatorCentralizes many-to-many communication in one hub to eliminate a web of references. Observer is one-to-many broadcast. Use Mediator when objects must coordinate; Observer when one announces and others independently react.
Reactive streams (Rx)A superset — Observer plus composition operators (map/filter/merge), backpressure, completion, and error channels. RxJS Observable is “Observer done industrially.”
Chain of ResponsibilityPasses a request along until one handler consumes it. Observer delivers to all observers; CoR stops at the first that handles it.

In an interview, when you say “Observer,” immediately bound it: “in-process, synchronous, subject holds direct refs; if they wanted cross-service or async I’d reach for Pub/Sub, and if it’s many-to-many I’d reach for Mediator.” That one sentence shows you know the neighborhood, not just the single pattern — which is exactly the level of fluency that separates an SSE answer from a junior one.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. You implement an Observer-based event system. A `View` subscribes to a long-lived `Store` at construction but the codebase never calls `unsubscribe`. The `View` is later removed from the screen and all other references to it are dropped. What is the most accurate description of what happens?

2. In the order-event example, `notify()` iterates over a *snapshot* (`[...this.observers]` / `list(self._observers)` / a copied container) rather than the live collection. What is the primary correctness reason for this?

3. In the order-event example, the `at` timestamp is produced with `Date.now()` (TS), `duration_cast<milliseconds>(...)` (C++), and `time.time_ns() // 1_000_000` (Python). Why is the Python code written that way instead of just calling `time.time()`?

4. Which of the following statements correctly distinguish the classic Observer pattern from a Pub/Sub message bus? (Select all that apply.) (select all)

5. You have a set of objects with mutually-dependent state: changing A should update B, changing B should update C, and some of those updates feed back into A. Which choice best reflects sound design judgment?

Design problem 6

Design a thread-safe, in-process `OrderEventHub` (a Subject) that lets multiple observers react to order state changes. Requirements: (1) concurrent subscribe/unsubscribe and notify from different threads must be safe; (2) subscribe must return an unsubscribe handle usable from a consumer's teardown; (3) one observer throwing must not prevent delivery to the others; (4) an observer that unsubscribes from inside its own callback must not corrupt delivery. Sketch the class (any one language), explain how you avoid holding a lock across observer callbacks, and state where you'd draw the line between this Observer implementation and reaching for Pub/Sub or Mediator instead.