Observer Pattern
Reacting to change without tight coupling
A stock ticker publishes a new price. A price display must refresh. An alert engine must check whether the price crossed a threshold. An analytics logger must record the tick. All three need to react to the same event, but none of them should be wired directly into the ticker. If the ticker calls each one by name, adding a fourth consumer means editing the ticker's source code — and the ticker should know nothing about displays, alerts, or analytics.
The naive version makes this coupling explicit and fragile:
The Observer pattern decouples the ticker from its consumers via a registration list.
1# Bad: the ticker is hard-coded to three specific consumers.
2# Adding an audit log means editing StockTicker.
3class StockTicker:
4 def __init__(self) -> None:
5 self._display = PriceDisplay()
6 self._alert = AlertEngine(threshold=150.0)
7 self._analytics = AnalyticsLogger()
8
9 def update_price(self, symbol: str, price: float) -> None:
10 self._price = price
11 self._display.refresh(symbol, price) # tight coupling
12 self._alert.check(symbol, price) # tight coupling
13 self._analytics.record(symbol, price) # tight coupling
How it works
The Observer pattern separates the publisher (the subject) from its consumers (the observers)
through a registration interface. The subject holds a list of observers. When its state changes,
it calls notify() on each one. New consumers register themselves at startup; the subject never
names them directly.
Adding a fourth consumer requires only a registration call at startup; the subject's source code does not change. This structure suits any case where one upstream event must fan out to multiple independent downstream reactions.
Good: publish via a registration list
1from __future__ import annotations
2from abc import ABC, abstractmethod
3
4class Observer(ABC):
5 @abstractmethod
6 def on_price_update(self, symbol: str, price: float) -> None: ...
7
8class StockTicker:
9 def __init__(self) -> None:
10 self._observers: list[Observer] = []
11 self._prices: dict[str, float] = {}
12
13 def subscribe(self, observer: Observer) -> None:
14 self._observers.append(observer)
15
16 def unsubscribe(self, observer: Observer) -> None:
17 self._observers.remove(observer)
18
19 def update_price(self, symbol: str, price: float) -> None:
20 self._prices[symbol] = price
21 self._notify(symbol, price)
22
23 def _notify(self, symbol: str, price: float) -> None:
24 for observer in list(self._observers): # snapshot avoids mutation during iteration
25 observer.on_price_update(symbol, price)
26
27class PriceDisplay(Observer):
28 def on_price_update(self, symbol: str, price: float) -> None:
29 print(f"[DISPLAY] {symbol}: ${price:.2f}")
30
31class AlertEngine(Observer):
32 def __init__(self, threshold: float) -> None:
33 self._threshold = threshold
34
35 def on_price_update(self, symbol: str, price: float) -> None:
36 if price > self._threshold:
37 print(f"[ALERT] {symbol} crossed ${self._threshold:.2f} — now ${price:.2f}")
38
39class AnalyticsLogger(Observer):
40 def on_price_update(self, symbol: str, price: float) -> None:
41 print(f"[LOG] tick {symbol}={price}")
42
43# Wire up at startup; the ticker knows none of these classes by name.
44ticker = StockTicker()
45ticker.subscribe(PriceDisplay())
46ticker.subscribe(AlertEngine(threshold=150.0))
47ticker.subscribe(AnalyticsLogger())
48
49ticker.update_price("AAPL", 145.30)
50ticker.update_price("AAPL", 152.10)
51# [DISPLAY] AAPL: $145.30
52# [LOG] tick AAPL=145.3
53# [DISPLAY] AAPL: $152.10
54# [ALERT] AAPL crossed $150.00 — now $152.10
55# [LOG] tick AAPL=152.1
The notify fan-out as a sequence
It helps to trace what happens when a price update arrives:
Each observer runs synchronously in this implementation. In a production system you would push to a queue and let each consumer process asynchronously, so a slow logger cannot stall a display refresh. The pattern is the same either way; only the delivery mechanism changes.
Push vs. pull notification
When the subject notifies its observers, it can deliver the changed data directly — this is
the push model. In the stock ticker example above, on_price_update(symbol, price) pushes
both pieces of state to every observer. Push is straightforward and requires no additional
calls, but it means every observer receives data whether or not it needs it. An observer that
cares only about the symbol still receives the price on every tick.
The pull model inverts this. The subject sends a minimal notification — often just a reference
to itself or a change token — and each observer queries the subject for only the state it
requires. For the ticker, that means exposing a getter such as ticker.get_price(symbol) and
notifying with just the changed symbol; an observer that tracks one symbol reads only that price.
This avoids sending data that observers will discard, and it lets different observers fetch
different fields. The trade-off is the extra call: each observer must reach back into the subject,
which re-introduces a dependency on the subject's interface.
Push suits cases where observers uniformly need the same data and the payload is small. Pull suits cases where different observers need different slices of a large subject, or where observers need to fetch related state that cannot be anticipated at notification time.
Lapsed listeners and memory leaks
The most common production bug in Observer implementations is the lapsed listener: an observer registers with a subject but never unsubscribes. The subject holds a reference to the observer in its list. As long as that reference exists, the garbage collector cannot reclaim the observer or any objects the observer itself references. In a long-running process this silently accumulates memory.
The fix is explicit unsubscription: call unsubscribe(observer) when the observer's work is
done, or when the component that owns it is destroyed. UI frameworks that use Observer
internally (event listeners, component lifecycles) typically expose a teardown hook — onDestroy,
componentWillUnmount, Disposable — precisely for this reason.
An alternative is to store observers as weak references. The subject's list holds a weak reference to each observer; if the observer has been garbage-collected, the notification loop skips the dead entry and removes it from the list. This avoids requiring callers to unsubscribe explicitly, but it means an observer may be silently dropped if nothing else holds a strong reference to it — a subtle lifetime bug in its own right. Explicit unsubscription is more predictable and the more common choice in production code.
When NOT to use this
Observer is the right tool when one upstream event fans out to multiple independent consumers and you want to add or remove consumers without touching the publisher. When there is exactly one consumer and no expectation of more, a direct method call is cleaner — every layer of indirection has a cost in readability and debuggability.
Circular subscription chains are a real hazard: observer A updates B which updates A. The
infinite loop is silent until it overflows the stack. If your observers write back to the same
subject they listen to, audit the update path carefully or make the subject ignore re-entrant
notifications. In a multithreaded context, the snapshot trick in the _notify method above
(iterating a copy of the list) prevents a ConcurrentModificationException when one observer
unsubscribes another, but you still need a lock around the registration list itself.