Push vs Pull, and the Pub/Sub Generalization
What This Concept Is
Observer is one-to-many notification inside a process. Two small design choices and one scaling move shape how it is actually used:
- Push model: the subject passes the updated value (or event object) to each observer.
- Pull model: the subject passes itself; observers read whatever they need.
- Pub/Sub: subject and subscriber are mediated by a broker or bus; they do not know each other's identity at all. Topics (or channels) route events.
Strictly speaking Pub/Sub is architectural, while Observer is a pattern at object granularity. The dispatcher changes identity and routing dependencies; payload and timing coupling remain. The boundary can be local or a network.
Why It Matters Here
Knowing the axis lets you pick the smallest structure that solves the problem:
- Two objects in the same process, few events? Observer, push.
- Many observers that need different slices of the state? Observer, pull.
- Events needing topic routing or independent delivery ownership? Consider Pub/Sub with explicit topics; module/team boundaries alone are insufficient.
Leaping to a full event bus when two subscribe calls would do is over-engineering. A new team or module alone does not require a bus; justify routing, ownership or delivery needs.
Concrete Example
Minimal topic-based pub/sub sketch in Python. This deliberately omits registration identity and idempotent disposal; diagnose it before comparing with L02:
from collections import defaultdict
from typing import Callable, Any
class EventBus:
def __init__(self):
self._topics: dict[str, list[Callable[[Any], None]]] = defaultdict(list)
def subscribe(self, topic: str, fn: Callable[[Any], None]) -> Callable[[], None]:
self._topics[topic].append(fn)
# Minimal sketch: remove is not idempotent and duplicate callbacks are ambiguous.
return lambda: self._topics[topic].remove(fn)
def publish(self, topic: str, payload: Any) -> None:
for fn in list(self._topics.get(topic, [])):
try:
fn(payload)
except Exception as e:
print(f"listener on {topic!r} failed: {e}")
bus = EventBus()
off = bus.subscribe("order.placed", lambda ev: print("email:", ev["id"]))
bus.subscribe("order.placed", lambda ev: print("metrics:", ev["id"]))
bus.publish("order.placed", {"id": 42})
off()
Publishers and subscribers never see each other. Routing is by topic string. This is the seed from which enterprise buses grow.
Push vs pull for the weather example:
// push
interface Observer { void update(double tempC, double humidity); }
// pull
interface Observer { void update(WeatherStation src); } // reads getTempC(), etc.
Push can communicate a stable event value. Pull can expose selected state through accessors but may observe newer state. Payload size alone does not determine the choice, especially when local calls pass references.
Common Confusion / Misconception
- Treating a Pub/Sub bus as magic decoupling. Subscribers still depend on the shape of the payload and the topic name. If those change, every consumer breaks. Decoupled types, not decoupled contracts.
- Topic-sprawl. A hundred ad-hoc topic names with no schema becomes worse than the direct coupling it replaced. Treat topic names and payload shapes as an API.
- Confusing async with pub/sub. A pub/sub call can be synchronous (in-process bus) or asynchronous (message broker). These are independent choices.
- Assuming delivery guarantees. Local delivery can stop on errors, removal or cancellation depending on its contract. Network delivery adds persistence and acknowledgement boundaries. A broker's delivery guarantee is not automatically a guarantee about a handler's external effects.
How To Use It
Decision questions when designing notification:
- Scope. Compare direct subscriptions with routed topics using actual ownership and delivery requirements.
- Payload. Compare immutable event values, shared references and pull accessors for consistency and cost.
- Broker. In-process dispatcher is enough until events must survive a restart or cross a network.
- Contract. Name the event type. Describe it once (a class, a schema, a TypeScript type) and reuse.
- Backpressure. If publishers can overwhelm subscribers, you need an explicit capacity policy, possibly using bounded queues or streaming (reactive libraries like RxJS, Project Reactor, Akka Streams).
Check Yourself
- What does push pass that pull does not?
- When does topic-based pub/sub stop being "just Observer with extra steps"?
- What does "a decoupled type, not a decoupled contract" mean?
- What kind of failure mode appears with network-based pub/sub that an in-process Observer does not have?
Mini Drill or Application
Take a desktop app's file watcher and three consumers: a UI panel, a backup service, and a search indexer.
- Model first with direct Observer, push model, one subject.
- Now add a fourth consumer from a different module and note what changed in the subject's imports.
- Refactor to a small topic-based bus (
file.changed,file.deleted). Show that the subject no longer mentions consumers at all. - Write a one-paragraph memo deciding whether topic routing is worth its cost. Keeping the first design is a valid result.
Read This Only If Stuck
- Head First: Publishers + Subscribers = Observer Pattern (book index)
- Head First: Observer Pattern Class Diagram (book index)
- GoF: Observer Collaborations (book index)
- GoF: Observer Implementation (book index)
Contract and transfer
The bus above is an intentionally minimal counterexample, not the L02 reference: calling its handle twice raises ValueError, callback equality cannot distinguish duplicate registrations, and nested publication is unrestricted. Repair it with per-registration identity and a declared protocol before reuse.
Push of an immutable event can retain the value as it was when the change happened. Pull of a mutable subject can see a newer value if another callback changes it first. In-process passing of a large object reference is not copying its bytes; across a network, encoding and transfer change the cost model. Selecting only the needed immutable fields is another option.
A bus does not automatically provide persistence, exactly-once processing, global order or cancellation. For a bounded queue, specify block, reject, drop or coalesce when full. Coalescing may be acceptable for a latest-temperature display and wrong for a ledger of accepted operations. P09 and experiment E1 explore this distinction. The required reactive sketch must show cleanup and capacity; no specific reactive framework installation is required.