Design PatternsIntermediate 13 min Lesson 10 of 13

Observer — Telling Interested Parties

Let one object announce that something happened and let any number of listeners react, without either side knowing the other.

Design Patterns · Lesson 10 of 13
0/13 done(0%)

What is it? #

The Observer pattern lets an object announce events, and lets other objects subscribe to be told when they happen.

The subject does not know who is listening. Listeners do not know about each other. Both sides only know the event.

It solves the problem of an important action accumulating unrelated follow-up work. Without it, place_order ends up calling email, analytics, inventory, loyalty points and the audit log directly.

The cost is traceability. When any number of listeners can react to an event, reading the code no longer tells you everything that happens.

Think of it like this #

A newsletter. The publisher writes once and sends to whoever subscribed. New subscribers arrive without the publisher changing anything; subscribers leave the same way.

The publisher does not know who reads it, and readers do not know about each other.

Simple example #

When an order is placed, several things must follow: a confirmation email, an analytics event, a loyalty points update, and an audit entry. Each is owned by a different part of the system.

Code #

PYTHON
from collections import defaultdict
from typing import Callable


class EventBus:
    def __init__(self):
        self._subscribers: dict[str, list[Callable]] = defaultdict(list)

    def subscribe(self, event: str, handler: Callable) -> Callable:
        self._subscribers[event].append(handler)
        return lambda: self._subscribers[event].remove(handler)   # unsubscribe

    def publish(self, event: str, payload: dict) -> None:
        for handler in list(self._subscribers[event]):            # copy: handlers may unsubscribe
            try:
                handler(payload)
            except Exception as exc:
                # One failing listener must not stop the others
                print(f"[event error] {event} handler failed: {exc}")


bus = EventBus()


# --- Listeners, each owned by its own part of the system ---
def send_confirmation(payload):
    print(f"email -> {payload['email']}: order {payload['order_id']} confirmed")

def track_analytics(payload):
    print(f"analytics -> order_placed value={payload['total']}")

def award_loyalty_points(payload):
    print(f"loyalty -> +{int(payload['total'] // 100)} points")

def write_audit_log(payload):
    print(f"audit -> order {payload['order_id']} placed")


bus.subscribe("order.placed", send_confirmation)
bus.subscribe("order.placed", track_analytics)
bus.subscribe("order.placed", award_loyalty_points)
unsubscribe_audit = bus.subscribe("order.placed", write_audit_log)


# --- The subject announces; it does not call anyone directly ---
class OrderService:
    def __init__(self, bus: EventBus):
        self.bus = bus

    def place(self, order: dict) -> None:
        # ... save the order, charge payment: the essential work
        print(f"order {order['id']} saved")
        self.bus.publish("order.placed", {
            "order_id": order["id"],
            "email": order["email"],
            "total": order["total"],
        })


OrderService(bus).place({"id": "A-1", "email": "[email protected]", "total": 2499.0})

unsubscribe_audit()        # listeners can come and go at runtime
TEXT
When to use it
  - one action triggers several independent follow-ups
  - the list of follow-ups changes, or differs per deployment
  - the follow-ups should not be able to break the main action

When NOT to use it
  - exactly one thing always happens next — just call it
  - the follow-up must complete for the main action to be correct
  - you need a guaranteed order between listeners

How it works #

EventBus maps event names to handler lists. subscribe adds one and returns a function that removes it, which is a tidy way to support unsubscribing.

publish iterates over a copy of the list. Without the copy, a handler that unsubscribes during dispatch would modify the list mid-iteration.

Each handler is wrapped in try/except. That decision matters: one failing listener should not prevent the others, and should not fail the order that was already saved. In production this would log properly rather than print.

OrderService.place does the essential work and then announces. It does not import the email module, the analytics client or the loyalty service, so it can be tested without any of them.

Adding a fifth follow-up means writing a function and subscribing it. OrderService never changes.

The important limitation is in the comment block. This dispatch is synchronous and in-process: if the application crashes after publishing, the handlers that had not run are simply lost. When follow-ups must not be lost, the event belongs in a durable message queue instead, which the System Design track covers.

Real-world use #

User interface frameworks are built on this pattern — click handlers are observers. So are Django signals, Rails callbacks and Node's EventEmitter.

At system scale it becomes publish-subscribe messaging: Kafka, RabbitMQ and cloud pub-sub services, where events cross process and machine boundaries and survive restarts.

Domain events are a common architectural use: OrderPlaced, PaymentFailed, UserRegistered. Each bounded part of the system reacts to what concerns it, which keeps modules decoupled.

The honest downside shows up during debugging. "Why did this email get sent?" can require searching for subscribers rather than reading a call stack. Teams mitigate it with a documented event catalogue and consistent naming.

Common mistakes #

  • Letting one listener’s exception break the publisher or the other listeners.
  • Relying on listener execution order, which is fragile and usually unspecified.
  • Using in-process events for work that must not be lost — use a durable queue.
  • Forgetting to unsubscribe, which keeps objects alive and causes duplicate handling.
  • Publishing events for everything until nobody can trace what happens.

Practice #

Build an event bus with user.registered handlers for a welcome email, a CRM sync and a metrics counter. Make one handler raise and prove the others still run. Then add unsubscribe and show that removing a handler stops it receiving further events.

Quick quiz

  1. 1. What does the Observer pattern decouple?

  2. 2. Why iterate over a copy of the handler list when publishing?

  3. 3. Why catch exceptions per handler?

  4. 4. When should you use a durable queue instead?

  5. 5. What is the main downside of this pattern?

Summary

  • Observers subscribe to events; the publisher does not know who is listening.
  • It keeps follow-up work out of the code that performs the main action.
  • Isolate handler failures so one listener cannot break the rest.
  • In-process events are lost on crash — use a queue when delivery matters.
  • The cost is traceability, so name and document events carefully.