Course contents

Observer — publish, subscribe, react

Sign in to run this lab and save your progress

Learn

Before you start

Observer is the pattern behind every event-driven system you'll ever work on. UI clicks. Django signals. RxJS streams. DOM events. Distributed message queues (in shape, if not exact implementation). Learn it here; you'll apply it forever.

The one-line version

Publisher notifies subscribers. Subscribers sign up and leave on their own. Publisher doesn't know what subscribers do; subscribers don't know about each other.

When Observer beats Facade

Both handle "one thing happens, N reactions". The difference:

  • Facade (last lesson) — FIXED, ORDERED reactions. Login ALWAYS goes: auth → audit → session → analytics. Every reaction must run. Order matters.
  • Observer — GROWING, INDEPENDENT reactions. Order placed: today 4 reactions, tomorrow 6, next quarter
    1. Each reaction is a plug-in.

Facade is a design decision baked into your code. Observer is a coordination pattern that lets other people (or your future self) add reactions later.

Where Observer shows up

  • Django signals (post_save, pre_delete) — subscribers register with @receiver.
  • NestJS event emitter@OnEvent('order.placed').
  • JavaScript's addEventListener — the DOM's Observer.
  • RxJS Observables — reactive-style Observer.
  • React state hooks — components subscribe to store changes.
  • File watchers — react to file changes.
  • Chat systems — every user's message triggers per-recipient reactions.
  • CI systems — code push notifies test runners, linters, deployers.

The pattern is EVERYWHERE. The name changes; the shape doesn't.

The best-effort trap

Observer notifiers face one big choice: what if a subscriber throws?

  • Best-effort (swallow) — other subscribers still run. Log the error. Nothing crashes.
  • Strict (propagate) — the whole publish operation fails. Callers see the error.

Both are valid; different systems want different behavior. Django signals: strict by default. NestJS EventEmitter: best-effort. This lesson uses best-effort — the typical "send emails / update analytics / etc." case doesn't want one flaky subscriber to break everything.

In real code, ALWAYS log the swallowed errors. Silent failures are how systems slowly drift into being broken without anyone noticing.

The subscription-order trap

Observers usually run in the order they registered. But relying on this is fragile:

  • Adding a new subscriber "just before" another one means you have to know the wiring order.
  • Two subscribers both wanting to go first causes bugs that only show up at runtime.

If order MATTERS, you probably want Facade (explicit ordering) or a Mediator. Observer is best for reactions that are truly INDEPENDENT.

The mid-notification-mutation subtlety

If a subscriber unsubscribes ITSELF during notification, and you're iterating self._subscribers directly, you get "RuntimeError: list changed size during iteration" or you silently skip some subscribers.

Fix: iterate over a COPY of the subscribers list. Our lesson does this via list(self._subscribers). Real production code does the same.

Observer vs Command vs State

Three behavioral patterns that get mixed up:

  • Observer — WHO REACTS when something happens
  • Command (next lesson) — WHAT ACTION happens, as a first-class object
  • State (final lesson) — HOW BEHAVIOR CHANGES based on the object's internal state

Different problems.

The decision cheat-sheet

Situation Pick
Fixed set of always-run reactions with order Facade
Growing / dynamic reactions, independent Observer
Cross-process events Message queue (HLD)
Global state changes trigger updates Observer (or Redux-style store)
Chat / two-way coordination Mediator (not in this course)

Your task

Observer is the in-process publish/subscribe pattern. When something happens on a Subject, all its registered Observers get notified — WITHOUT the Subject knowing what they do, and without Observers knowing about each other.

The problem

Your e-commerce app places an order. When it does, these things need to happen:

  • Analytics — track "order_placed" event
  • Warehouse — reserve inventory
  • Email — send order confirmation
  • Fraud check — score the order in the background

Naive first attempt — OrderService coordinates all four:

class OrderService:
    def __init__(self, analytics, warehouse, mailer, fraud):
        self._analytics = analytics
        self._warehouse = warehouse
        self._mailer = mailer
        self._fraud = fraud

    def place(self, order):
        save_order(order)
        self._analytics.track("order_placed", order.id)
        self._warehouse.reserve(order.items)
        self._mailer.send_confirmation(order.email, order.id)
        self._fraud.score_async(order.id)

This is Facade (lesson 7) — and for a fixed set of reactions that always run, Facade is right. But watch what happens as the system grows:

  • Marketing adds "loyalty points" as a reaction to orders
  • Ops adds an "audit log" reaction
  • Fraud team wants a second scorer running in parallel
  • Different environments want different combinations

Every reaction is a code change to place(). Every new subscriber couples to OrderService.

Facade coordinates a FIXED sequence. Observer supports an OPEN-ENDED set of subscribers — added and removed as the app runs.

Questions to ask about the requirements

  1. Is the set of reactions FIXED or GROWING? Fixed → Facade. Growing / dynamic → Observer.
  2. Do all reactions HAVE to run? If yes, ordered, bounded → Facade. If independent and best-effort → Observer.
  3. Do reactions need to know about each other? If yes, Observer isn't enough — you need Mediator (not in this course). If no, Observer is perfect.
  4. What happens if one subscriber throws an error? Observer has to decide: keep notifying others (best-effort) or stop (strict). Depends on requirements.
  5. Is this in-process or cross-process? Observer is in-process. Cross-process = message queue / event bus (HLD, different pattern).

For our order-placed scenario: growing set of reactions, best-effort, in-process — Observer fits.

Different designs, compared

Design A — hardcoded coordination (Facade)

Above.

  • Good: Explicit. Order is guaranteed. Easy to understand.
  • Bad: Every new reaction edits place(). Every reaction becomes a required dependency of OrderService.
  • When it wins: Fixed set of always-run reactions with coordination rules. Login flow (last lesson's Facade).

Design B — Observer

class OrderService:
    def __init__(self):
        self._subscribers = []

    def subscribe(self, callback):
        self._subscribers.append(callback)

    def place(self, order):
        save_order(order)
        for cb in self._subscribers:
            try:
                cb(order)
            except Exception:
                ...   # decide: log and continue, or propagate

# Wire subscribers at startup:
svc = OrderService()
svc.subscribe(lambda o: analytics.track("order_placed", o.id))
svc.subscribe(lambda o: warehouse.reserve(o.items))
svc.subscribe(lambda o: mailer.send_confirmation(o.email, o.id))
  • Good: Adding a reaction = one subscribe call. Zero changes to OrderService. Reactions are independent — one failing doesn't break the others.
  • Bad: Reaction ORDER is subtle (depends on registration order — often not obvious from reading place()). Errors hide unless you log them. Harder to trace end-to-end.
  • When it wins: Growing set of reactions, independent, best-effort.

Design C — global event bus

A separate EventBus singleton every service publishes to.

  • Good: Fully separates the publisher from subscribers. Works across modules without wiring.
  • Bad: Global state. Very hard to figure out who reacts to what. Testing means turning off or faking the global bus.
  • When it wins: Very large codebases where wiring subscribers by hand is impractical. NOT the default for interviews.

The locked-in choice: Design B (Observer)

Reviewing the requirements:

  • Growing set of reactions. → Rules out A.
  • Coupling is a real concern. → Rules out C (which just hides coupling behind a global).
  • Order-by-registration is fine for this use case.

Design B wins.

Where Observer shows up in real code

  • UI event handlers — click a button, 5 things fire.
  • Django signalspost_save, pre_delete, etc.
  • NestJS @OnEvent() — event bus with typed events.
  • RxJS / Reactive libraries — every stream is an Observer pattern.
  • DOM MutationObserver — react to DOM changes.
  • File system watchers — reactor to file changes (inotify, chokidar).

Mechanics

class OrderService:
    def __init__(self):
        self._orders = []
        self._subscribers = []

    def subscribe(self, callback):
        self._subscribers.append(callback)
        return lambda: self._subscribers.remove(callback)  # unsub

    def place(self, order):
        self._orders.append(order)
        for cb in list(self._subscribers):
            try:
                cb(order)
            except Exception as e:
                # Best-effort: swallow so one bad subscriber
                # doesn't break others. Real code logs this.
                pass

The list(...) copy of _subscribers before iterating matters: a subscriber that unsubscribes ITSELF during notification would mutate the list mid-iteration otherwise.

Your task

In main.py build:

  1. Order class — simple dataclass-like, __init__(self, order_id, email, total) storing the three.
  2. OrderService:
    • __init__self._orders = [], self._subscribers = []
    • subscribe(callback) — appends the callback, returns a ZERO-ARG function that unsubscribes it when called
    • place(order) — appends to _orders, then invokes every subscriber with the order. A subscriber raising must NOT prevent later subscribers from running.
    • subscriber_count() — returns the current count

Structural tests enforce:

  • Multiple subscribers all get called on .place().
  • Subscribe order = notification order.
  • An unsubscribed callback does NOT get called after the unsubscribe.
  • A subscriber that RAISES doesn't stop later subscribers.
  • Subscribers can be added dynamically at runtime.

Click Submit to run the tests.

Starter files

Preview only — open the lab to edit and run.

files_dir
12-observer-starter