The pattern behind every notification you have ever received, and the one interviewers use to find out whether you have thought about what happens when a listener goes wrong.
Place a food order and watch your phone. Order accepted. A minute later, picked up. The restaurant's tablet beeped at the first of those, the rider's app changed at the second, and none of the three screens asked for the news. The order changed, and the order told them.
That is the Observer pattern: when one object changes, it announces the change to a list of listeners, and it knows nothing about them beyond the one method it calls. It sits behind the notifications in the elevator, the sinks in the pub-sub logger, the outbid alerts in the auction and the board's move lines in snake and ladder, and you use it every time you attach a click handler.
The pattern itself fits in ten lines. Interviewers probe what is around it: push or pull, who unsubscribes, what happens when a listener throws, and when "observer" is the wrong word because you need a message broker. This lesson starts from the tracker most people write first, refactors it in four languages, then hardens it against those questions.
A newspaper round is Observer: one paper, printed once, and a list of addresses. Subscribers join and leave, and the printer never knows their faces.
The first version: the tracker calls everyone#
The first version ships with three screens. The code that changes an order's status simply calls each of them:
# order_tracker_v1.py: the tracker calls every recipient itself
class CustomerApp:
def show(self, text: str) -> None:
print("customer app:", text)
class RestaurantTablet:
def beep(self, text: str) -> None:
print("tablet:", text)
class RiderApp:
def update(self, text: str) -> None:
print("rider app:", text)
class OrderTracker:
def __init__(self, customer_app, tablet, rider_app) -> None:
self.customer_app, self.tablet, self.rider_app = customer_app, tablet, rider_app
def set_status(self, order_id: int, status: str) -> None:
self.customer_app.show(f"order {order_id} is {status}")
self.tablet.beep(f"order {order_id} is {status}")
self.rider_app.update(f"order {order_id} is {status}")
tracker = OrderTracker(CustomerApp(), RestaurantTablet(), RiderApp())
tracker.set_status(42, "accepted")// OrderTrackerV1.java: the tracker calls every recipient itself
class OrderTrackerV1 {
static class CustomerApp {
void show(String text) { System.out.println("customer app: " + text); }
}
static class RestaurantTablet {
void beep(String text) { System.out.println("tablet: " + text); }
}
static class RiderApp {
void update(String text) { System.out.println("rider app: " + text); }
}
static class OrderTracker {
private final CustomerApp customerApp;
private final RestaurantTablet tablet;
private final RiderApp riderApp;
OrderTracker(CustomerApp customerApp, RestaurantTablet tablet, RiderApp riderApp) {
this.customerApp = customerApp;
this.tablet = tablet;
this.riderApp = riderApp;
}
void setStatus(int orderId, String status) {
customerApp.show("order " + orderId + " is " + status);
tablet.beep("order " + orderId + " is " + status);
riderApp.update("order " + orderId + " is " + status);
}
}
public static void main(String[] args) {
OrderTracker tracker = new OrderTracker(new CustomerApp(), new RestaurantTablet(), new RiderApp());
tracker.setStatus(42, "accepted");
}
}// order_tracker_v1.cpp: the tracker calls every recipient itself
#include <iostream>
#include <string>
struct CustomerApp {
void show(const std::string& text) const { std::cout << "customer app: " << text << "\n"; }
};
struct RestaurantTablet {
void beep(const std::string& text) const { std::cout << "tablet: " << text << "\n"; }
};
struct RiderApp {
void update(const std::string& text) const { std::cout << "rider app: " << text << "\n"; }
};
class OrderTracker {
const CustomerApp& customerApp_;
const RestaurantTablet& tablet_;
const RiderApp& riderApp_;
public:
OrderTracker(const CustomerApp& c, const RestaurantTablet& t, const RiderApp& r) : customerApp_(c), tablet_(t), riderApp_(r) {}
void setStatus(int orderId, const std::string& status) const {
std::string text = "order " + std::to_string(orderId) + " is " + status;
customerApp_.show(text);
tablet_.beep(text);
riderApp_.update(text);
}
};
int main() {
CustomerApp customerApp;
RestaurantTablet tablet;
RiderApp riderApp;
OrderTracker tracker(customerApp, tablet, riderApp);
tracker.setStatus(42, "accepted");
}// orderTrackerV1.js: the tracker calls every recipient itself
class CustomerApp {
show(text) {
console.log("customer app:", text);
}
}
class RestaurantTablet {
beep(text) {
console.log("tablet:", text);
}
}
class RiderApp {
update(text) {
console.log("rider app:", text);
}
}
class OrderTracker {
constructor(customerApp, tablet, riderApp) {
Object.assign(this, { customerApp, tablet, riderApp });
}
setStatus(orderId, status) {
this.customerApp.show(`order ${orderId} is ${status}`);
this.tablet.beep(`order ${orderId} is ${status}`);
this.riderApp.update(`order ${orderId} is ${status}`);
}
}
const tracker = new OrderTracker(new CustomerApp(), new RestaurantTablet(), new RiderApp());
tracker.setStatus(42, "accepted");customer app: order 42 is accepted
tablet: order 42 is accepted
rider app: order 42 is acceptedIt works, and for three recipients it reads fine. Notice what OrderTracker has to know: three classes, and the method each is called (show, beep, update). Here is what the next six months do to it:
- Every new recipient edits the tracker. SMS, a kitchen display, an analytics feed, a support dashboard: each is a new constructor argument and a new line in the file that already runs every order.
- The tracker can't be tested alone. To test
set_status, you build all three recipients, or three fakes. - One broken recipient breaks the rest. If the tablet's printer throws, the rider never hears about the order, because the call to the rider is on the next line.
- The order's code knows about screens. Renaming a screen's method is a change in the code that moves orders.
Before the refactor: who should know whom?
In the tracker, which way does the knowledge point, and which way should it point?
One strong answer
Now the order's side knows every screen; it should be the other way round. A screen has to know about orders, because it shows one. An order has no reason to know that a tablet exists. The fix is to make the order hold a list of "somebody who wants to hear" behind one interface, and let each screen put itself on the list. The order then knows one method name and nothing else.
The pattern#
The book that named the pattern (Design Patterns, 1994, by Gamma, Helm, Johnson and Vlissides) states its intent in one sentence: "Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically."
In our app that gives four roles:
| Role | In the delivery app | Its one job |
|---|---|---|
| Subject (the thing being watched) | Order |
Keep the list of listeners; announce each change to all of them |
| Observer (the interface) | a listener with on_status(event) |
Say what every listener can do |
| Concrete observers | CustomerApp, RestaurantTablet, RiderApp |
React in their own way |
| Event | StatusEvent(order_id, old_status, new_status) |
Carry what changed |
%% title: Observer: one order, many listeners
classDiagram
class Order {
+order_id int
+status str
+subscribe(listener) Subscription
+set_status(new_status)
}
class Listener {
<<interface>>
+on_status(event)
}
class StatusEvent {
+order_id int
+old_status str
+new_status str
}
class CustomerApp {
+on_status(event)
}
class RestaurantTablet {
+on_status(event)
}
class RiderApp {
+on_status(event)
}
Order "1" o-- "many" Listener : notifies
Listener <|.. CustomerApp
Listener <|.. RestaurantTablet
Listener <|.. RiderApp
Listener ..> StatusEvent : receives
RiderApp --> Order : asks (pull)Read the arrows. Order points at the interface with a hollow diamond: it holds many listeners, and it has no arrow to any screen, so it compiles and runs without knowing one exists. The dotted arrows with hollow heads are "implements": each screen promises on_status. The last arrow is the odd one out: RiderApp keeps a reference back to the order and asks it for the status when it is told something changed. That is "pull", and the next section compares it with "push". In the code, a listener is anything that can be called with one event: Python, C++ and JavaScript take any function, and Java a one-method Listener interface, so each screen subscribes its on_status method.
Observer has a subject that keeps a list, a listener interface with one method, and listeners that react in their own way. Pull is a reference back.
The code#
Order holds its listeners in a map keyed by a rising number, so they are kept in the order they subscribed. subscribe hands back a function that removes the listener again. set_status builds the event, changes the status, and calls every listener on a copy of the list.
# order_observer.py: the order announces a change; listeners it has never heard of react
from dataclasses import dataclass
from typing import Callable, Dict
@dataclass(frozen=True)
class StatusEvent:
order_id: int
old_status: str
new_status: str
Listener = Callable[[StatusEvent], None]
class Order:
"""The subject: it keeps a list of listeners and knows nothing else about them."""
def __init__(self, order_id: int) -> None:
self.order_id = order_id
self.status = "placed"
self._listeners: Dict[int, Listener] = {} # keyed by a rising id, so the order is subscription order
self._next_id = 1
def subscribe(self, listener: Listener) -> Callable[[], None]:
key = self._next_id
self._next_id += 1
self._listeners[key] = listener
def unsubscribe() -> None:
self._listeners.pop(key, None)
return unsubscribe
def set_status(self, new_status: str) -> None:
event = StatusEvent(self.order_id, self.status, new_status)
self.status = new_status
for listener in list(self._listeners.values()): # a copy, so edits during the loop are safe
listener(event)
class CustomerApp:
def on_status(self, e: StatusEvent) -> None: # push: the event carries what changed
print(f"customer app: {e.old_status} -> {e.new_status}")
class RestaurantTablet:
def on_status(self, e: StatusEvent) -> None:
print(f"tablet: order {e.order_id} is {e.new_status}")
class RiderApp:
def __init__(self, order: Order) -> None:
self.order = order
def on_status(self, e: StatusEvent) -> None: # pull: ignore the event, ask the order
print(f"rider app: the order says {self.order.status}")
order = Order(42)
order.subscribe(CustomerApp().on_status)
order.subscribe(RestaurantTablet().on_status)
unsubscribe_rider = order.subscribe(RiderApp(order).on_status)
order.set_status("accepted")
order.set_status("picked_up")
unsubscribe_rider()
print("-- rider app unsubscribed")
order.set_status("delivered")// OrderObserver.java: the order announces a change; listeners it has never heard of react
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
class OrderObserver {
record StatusEvent(int orderId, String oldStatus, String newStatus) {}
interface Listener {
void onStatus(StatusEvent e);
}
/** The subject: it keeps a list of listeners and knows nothing else about them. */
static final class Order {
final int orderId;
String status = "placed";
private final Map<Integer, Listener> listeners = new LinkedHashMap<>(); // keyed by a rising id: subscription order
private int nextId = 1;
Order(int orderId) { this.orderId = orderId; }
Runnable subscribe(Listener listener) {
int key = nextId++;
listeners.put(key, listener);
return () -> listeners.remove(key);
}
void setStatus(String newStatus) {
StatusEvent event = new StatusEvent(orderId, status, newStatus);
status = newStatus;
for (Listener listener : new ArrayList<>(listeners.values())) { // a copy, so edits during the loop are safe
listener.onStatus(event);
}
}
}
static final class CustomerApp {
void onStatus(StatusEvent e) { // push: the event carries what changed
System.out.println("customer app: " + e.oldStatus() + " -> " + e.newStatus());
}
}
static final class RestaurantTablet {
void onStatus(StatusEvent e) {
System.out.println("tablet: order " + e.orderId() + " is " + e.newStatus());
}
}
static final class RiderApp {
private final Order order;
RiderApp(Order order) { this.order = order; }
void onStatus(StatusEvent e) { // pull: ignore the event, ask the order
System.out.println("rider app: the order says " + order.status);
}
}
public static void main(String[] args) {
Order order = new Order(42);
order.subscribe(new CustomerApp()::onStatus);
order.subscribe(new RestaurantTablet()::onStatus);
Runnable unsubscribeRider = order.subscribe(new RiderApp(order)::onStatus);
order.setStatus("accepted");
order.setStatus("picked_up");
unsubscribeRider.run();
System.out.println("-- rider app unsubscribed");
order.setStatus("delivered");
}
}// order_observer.cpp: the order announces a change; listeners it has never heard of react
#include <functional>
#include <iostream>
#include <map>
#include <string>
#include <utility>
struct StatusEvent {
int orderId;
std::string oldStatus;
std::string newStatus;
};
using Listener = std::function<void(const StatusEvent&)>;
// The subject: it keeps a list of listeners and knows nothing else about them.
class Order {
std::map<int, Listener> listeners_; // keyed by a rising id, so the order is subscription order
int nextId_ = 1;
public:
int orderId;
std::string status = "placed";
explicit Order(int id) : orderId(id) {}
std::function<void()> subscribe(Listener listener) {
int key = nextId_++;
listeners_[key] = std::move(listener);
return [this, key] { listeners_.erase(key); };
}
void setStatus(const std::string& newStatus) {
StatusEvent event{orderId, status, newStatus};
status = newStatus;
auto copy = listeners_; // a copy, so edits during the loop are safe
for (const auto& entry : copy) entry.second(event);
}
};
struct CustomerApp {
void onStatus(const StatusEvent& e) const { // push: the event carries what changed
std::cout << "customer app: " << e.oldStatus << " -> " << e.newStatus << "\n";
}
};
struct RestaurantTablet {
void onStatus(const StatusEvent& e) const {
std::cout << "tablet: order " << e.orderId << " is " << e.newStatus << "\n";
}
};
struct RiderApp {
const Order& order;
void onStatus(const StatusEvent&) const { // pull: ignore the event, ask the order
std::cout << "rider app: the order says " << order.status << "\n";
}
};
int main() {
Order order(42);
CustomerApp customer;
RestaurantTablet tablet;
RiderApp rider{order};
order.subscribe([&](const StatusEvent& e) { customer.onStatus(e); });
order.subscribe([&](const StatusEvent& e) { tablet.onStatus(e); });
auto unsubscribeRider = order.subscribe([&](const StatusEvent& e) { rider.onStatus(e); });
order.setStatus("accepted");
order.setStatus("picked_up");
unsubscribeRider();
std::cout << "-- rider app unsubscribed\n";
order.setStatus("delivered");
}// orderObserver.js: the order announces a change; listeners it has never heard of react
/** The subject: it keeps a list of listeners and knows nothing else about them. */
class Order {
constructor(orderId) {
this.orderId = orderId;
this.status = "placed";
this.listeners = new Map(); // keyed by a rising id, so the order is subscription order
this.nextId = 1;
}
subscribe(listener) {
const key = this.nextId++;
this.listeners.set(key, listener);
return () => this.listeners.delete(key);
}
setStatus(newStatus) {
const event = { orderId: this.orderId, oldStatus: this.status, newStatus };
this.status = newStatus;
for (const listener of [...this.listeners.values()]) listener(event); // a copy, so edits during the loop are safe
}
}
class CustomerApp {
onStatus(e) { // push: the event carries what changed
console.log(`customer app: ${e.oldStatus} -> ${e.newStatus}`);
}
}
class RestaurantTablet {
onStatus(e) {
console.log(`tablet: order ${e.orderId} is ${e.newStatus}`);
}
}
class RiderApp {
constructor(order) {
this.order = order;
}
onStatus(e) { // pull: ignore the event, ask the order
console.log(`rider app: the order says ${this.order.status}`);
}
}
const order = new Order(42);
const customer = new CustomerApp();
const tablet = new RestaurantTablet();
const rider = new RiderApp(order);
order.subscribe((e) => customer.onStatus(e));
order.subscribe((e) => tablet.onStatus(e));
const unsubscribeRider = order.subscribe((e) => rider.onStatus(e));
order.setStatus("accepted");
order.setStatus("picked_up");
unsubscribeRider();
console.log("-- rider app unsubscribed");
order.setStatus("delivered");All four print the same thing:
customer app: placed -> accepted
tablet: order 42 is accepted
rider app: the order says accepted
customer app: accepted -> picked_up
tablet: order 42 is picked_up
rider app: the order says picked_up
-- rider app unsubscribed
customer app: picked_up -> delivered
tablet: order 42 is deliveredCheck one thing by hand. The order starts as placed, so the first event says placed -> accepted and the customer app prints exactly that. After the rider app unsubscribes, the last status change prints two lines, not three.
What each language brings to the same design:
- Python: any callable is a listener, so a bound method such as
CustomerApp().on_statusis enough. Adictkeeps insertion order, which gives the listeners a defined order. - Java: a one-method
interface(aConsumer<StatusEvent>would do equally well), and method references such asnew CustomerApp()::onStatusfor the listeners.LinkedHashMapkeeps the order. - C++: a
std::function<void(const StatusEvent&)>holds any callable.std::functioncan't be compared, so listeners are removed by their key, not by value. The subject must outlive the unsubscribe functions it hands out. - JavaScript: functions are values, and a
Mapkeeps insertion order. The language's ownEventTargetand Node'sEventEmitterare this pattern, ready-made.
Push or pull?#
The three listeners above use both styles on purpose. The customer app and the tablet are told what changed: push. The rider app is only told that something changed and asks the order what: pull.
Push: the event carries the details
- The listener gets
old_statusandnew_statuswith the call - It doesn't need a reference to the subject
- Every listener gets the same data, wanted or not
- An event class has to be designed, and changing it touches every listener
Pull: the event is a nudge
- The listener asks the subject for what it needs
- Each listener reads only its own slice
- It needs a reference to the subject, so it is tied to it
- By the time it asks, the subject may have changed again
A good default is push with a small immutable event: it works the same in-process, in tests and, later, in a message sent between machines, where there is nothing to pull from. Reach for pull when the data is large and listeners each need a different part of it. One caution: with asynchronous notification a pulling listener sees the latest state, which may have skipped steps. Sometimes that is exactly right (a screen that shows only the current status doesn't care about the steps between), but know that you chose it.
Unsubscribing, ordering and failure#
The basic version has five weak spots, and a good interview answer names each one. The next program fixes three of them and shows each on its own small order; the other two, the order of calls and synchronous delivery, are decisions you state:
# safe_notify.py: a failing listener, edits during a notification, and a listener nobody removed
from dataclasses import dataclass
from typing import Callable, List
@dataclass(frozen=True)
class StatusEvent:
order_id: int
old_status: str
new_status: str
class Subscription:
def __init__(self, order: "Order", name: str, fn: Callable[[StatusEvent], None]) -> None:
self.order, self.name, self.fn, self.active = order, name, fn, True
def cancel(self) -> None: # safe to call twice, and safe to call from inside a notification
self.active = False
if self in self.order._subs:
self.order._subs.remove(self)
class Order:
def __init__(self, order_id: int) -> None:
self.order_id = order_id
self.status = "placed"
self._subs: List[Subscription] = []
def subscribe(self, name: str, fn: Callable[[StatusEvent], None]) -> Subscription:
sub = Subscription(self, name, fn)
self._subs.append(sub)
return sub
def names(self) -> str:
return ", ".join(s.name for s in self._subs) or "nobody"
def set_status(self, new_status: str) -> List[str]:
"""Listeners hear an event in the order they subscribed. Returns the failures, 'name: message' each."""
event = StatusEvent(self.order_id, self.status, new_status)
self.status = new_status
failures: List[str] = []
for sub in list(self._subs): # a snapshot: a listener added now first hears the NEXT event
if not sub.active: # cancelled earlier in this same round: it does not hear this one
continue
try:
sub.fn(event)
except Exception as err: # one broken listener must not silence the others
failures.append(f"{sub.name}: {err}")
return failures
def say(name: str) -> Callable[[StatusEvent], None]:
return lambda e: print(f" {name}: {e.new_status}")
def jammed(e: StatusEvent) -> None:
raise RuntimeError("printer jammed")
print("1. a failing listener")
order = Order(42)
order.subscribe("tablet", jammed)
order.subscribe("customer", say("customer"))
order.subscribe("rider", say("rider"))
print(" failures:", "; ".join(order.set_status("accepted")) or "none")
print("2. edits during a notification")
order = Order(43)
subs = {}
def customer(e: StatusEvent) -> None:
print(f" customer: {e.new_status}")
subs["rider"].cancel() # the rider is removed before its turn
if "survey" not in subs:
subs["survey"] = order.subscribe("survey", say("survey")) # added mid-round
subs["customer"] = order.subscribe("customer", customer)
subs["rider"] = order.subscribe("rider", say("rider"))
order.set_status("accepted") # the rider is skipped, the survey is not heard yet
print(" subscribed now:", order.names())
order.set_status("picked_up") # the survey hears this one
print("3. a screen that was closed but never unsubscribed")
order = Order(44)
screen = order.subscribe("order screen", say("order screen"))
order.set_status("accepted")
print(" screen closed, subscribed:", order.names())
screen.cancel()
print(" after cancel, subscribed:", order.names())// SafeNotify.java: a failing listener, edits during a notification, and a listener nobody removed
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
class SafeNotify {
record StatusEvent(int orderId, String oldStatus, String newStatus) {}
static final class Subscription {
private final Order order;
final String name;
final Consumer<StatusEvent> fn;
boolean active = true;
Subscription(Order order, String name, Consumer<StatusEvent> fn) {
this.order = order;
this.name = name;
this.fn = fn;
}
void cancel() { // safe to call twice, and safe to call from inside a notification
active = false;
order.subs.remove(this);
}
}
static final class Order {
final int orderId;
String status = "placed";
final List<Subscription> subs = new ArrayList<>();
Order(int orderId) { this.orderId = orderId; }
Subscription subscribe(String name, Consumer<StatusEvent> fn) {
Subscription sub = new Subscription(this, name, fn);
subs.add(sub);
return sub;
}
String names() {
if (subs.isEmpty()) return "nobody";
List<String> names = new ArrayList<>();
for (Subscription s : subs) names.add(s.name);
return String.join(", ", names);
}
/** Listeners hear an event in the order they subscribed. Returns the failures, "name: message" each. */
List<String> setStatus(String newStatus) {
StatusEvent event = new StatusEvent(orderId, status, newStatus);
status = newStatus;
List<String> failures = new ArrayList<>();
for (Subscription sub : new ArrayList<>(subs)) { // a snapshot: a listener added now first hears the NEXT event
if (!sub.active) continue; // cancelled earlier in this same round: it does not hear this one
try {
sub.fn.accept(event);
} catch (RuntimeException err) { // one broken listener must not silence the others
failures.add(sub.name + ": " + err.getMessage());
}
}
return failures;
}
}
static Consumer<StatusEvent> say(String name) {
return e -> System.out.println(" " + name + ": " + e.newStatus());
}
static String joined(List<String> failures) {
return failures.isEmpty() ? "none" : String.join("; ", failures);
}
public static void main(String[] args) {
System.out.println("1. a failing listener");
Order first = new Order(42);
first.subscribe("tablet", e -> { throw new RuntimeException("printer jammed"); });
first.subscribe("customer", say("customer"));
first.subscribe("rider", say("rider"));
System.out.println(" failures: " + joined(first.setStatus("accepted")));
System.out.println("2. edits during a notification");
Order second = new Order(43);
Map<String, Subscription> subs = new HashMap<>();
subs.put("customer", second.subscribe("customer", e -> {
System.out.println(" customer: " + e.newStatus());
subs.get("rider").cancel(); // the rider is removed before its turn
if (!subs.containsKey("survey")) subs.put("survey", second.subscribe("survey", say("survey"))); // added mid-round
}));
subs.put("rider", second.subscribe("rider", say("rider")));
second.setStatus("accepted"); // the rider is skipped, the survey is not heard yet
System.out.println(" subscribed now: " + second.names());
second.setStatus("picked_up"); // the survey hears this one
System.out.println("3. a screen that was closed but never unsubscribed");
Order third = new Order(44);
Subscription screen = third.subscribe("order screen", say("order screen"));
third.setStatus("accepted");
System.out.println(" screen closed, subscribed: " + third.names());
screen.cancel();
System.out.println(" after cancel, subscribed: " + third.names());
}
}// safe_notify.cpp: a failing listener, edits during a notification, and a listener nobody removed
#include <algorithm>
#include <functional>
#include <iostream>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
struct StatusEvent {
int orderId;
std::string oldStatus;
std::string newStatus;
};
using Listener = std::function<void(const StatusEvent&)>;
class Order;
struct Subscription {
Order* order;
std::string name;
Listener fn;
bool active = true;
void cancel(); // safe to call twice, and safe to call from inside a notification
};
class Order {
std::vector<std::shared_ptr<Subscription>> subs_;
public:
int orderId;
std::string status = "placed";
explicit Order(int id) : orderId(id) {}
std::shared_ptr<Subscription> subscribe(const std::string& name, Listener fn) {
auto sub = std::make_shared<Subscription>(Subscription{this, name, std::move(fn)});
subs_.push_back(sub);
return sub;
}
void remove(const Subscription* sub) {
subs_.erase(std::remove_if(subs_.begin(), subs_.end(), [&](const auto& s) { return s.get() == sub; }), subs_.end());
}
std::string names() const {
if (subs_.empty()) return "nobody";
std::string out;
for (const auto& s : subs_) out += (out.empty() ? "" : ", ") + s->name;
return out;
}
// Listeners hear an event in the order they subscribed. Returns the failures, "name: message" each.
std::vector<std::string> setStatus(const std::string& newStatus) {
StatusEvent event{orderId, status, newStatus};
status = newStatus;
std::vector<std::string> failures;
auto snapshot = subs_; // a snapshot: a listener added now first hears the NEXT event
for (const auto& sub : snapshot) {
if (!sub->active) continue; // cancelled earlier in this same round: it does not hear this one
try {
sub->fn(event);
} catch (const std::exception& err) { // one broken listener must not silence the others
failures.push_back(sub->name + ": " + err.what());
}
}
return failures;
}
};
void Subscription::cancel() {
active = false;
order->remove(this);
}
Listener say(const std::string& name) {
return [name](const StatusEvent& e) { std::cout << " " << name << ": " << e.newStatus << "\n"; };
}
std::string joined(const std::vector<std::string>& failures) {
if (failures.empty()) return "none";
std::string out;
for (const auto& f : failures) out += (out.empty() ? "" : "; ") + f;
return out;
}
int main() {
std::cout << "1. a failing listener\n";
Order first(42);
first.subscribe("tablet", [](const StatusEvent&) { throw std::runtime_error("printer jammed"); });
first.subscribe("customer", say("customer"));
first.subscribe("rider", say("rider"));
std::string failures = joined(first.setStatus("accepted"));
std::cout << " failures: " << failures << "\n";
std::cout << "2. edits during a notification\n";
Order second(43);
std::map<std::string, std::shared_ptr<Subscription>> subs;
subs["customer"] = second.subscribe("customer", [&](const StatusEvent& e) {
std::cout << " customer: " << e.newStatus << "\n";
subs["rider"]->cancel(); // the rider is removed before its turn
if (!subs.count("survey")) subs["survey"] = second.subscribe("survey", say("survey")); // added mid-round
});
subs["rider"] = second.subscribe("rider", say("rider"));
second.setStatus("accepted"); // the rider is skipped, the survey is not heard yet
std::cout << " subscribed now: " << second.names() << "\n";
second.setStatus("picked_up"); // the survey hears this one
std::cout << "3. a screen that was closed but never unsubscribed\n";
Order third(44);
auto screen = third.subscribe("order screen", say("order screen"));
third.setStatus("accepted");
std::cout << " screen closed, subscribed: " << third.names() << "\n";
screen->cancel();
std::cout << " after cancel, subscribed: " << third.names() << "\n";
}// safeNotify.js: a failing listener, edits during a notification, and a listener nobody removed
class Subscription {
constructor(order, name, fn) {
Object.assign(this, { order, name, fn, active: true });
}
cancel() { // safe to call twice, and safe to call from inside a notification
this.active = false;
const i = this.order.subs.indexOf(this);
if (i >= 0) this.order.subs.splice(i, 1);
}
}
class Order {
constructor(orderId) {
this.orderId = orderId;
this.status = "placed";
this.subs = [];
}
subscribe(name, fn) {
const sub = new Subscription(this, name, fn);
this.subs.push(sub);
return sub;
}
names() {
return this.subs.map((s) => s.name).join(", ") || "nobody";
}
// Listeners hear an event in the order they subscribed. Returns the failures, "name: message" each.
setStatus(newStatus) {
const event = { orderId: this.orderId, oldStatus: this.status, newStatus };
this.status = newStatus;
const failures = [];
for (const sub of [...this.subs]) { // a snapshot: a listener added now first hears the NEXT event
if (!sub.active) continue; // cancelled earlier in this same round: it does not hear this one
try {
sub.fn(event);
} catch (err) { // one broken listener must not silence the others
failures.push(`${sub.name}: ${err.message}`);
}
}
return failures;
}
}
const say = (name) => (e) => console.log(` ${name}: ${e.newStatus}`);
console.log("1. a failing listener");
const first = new Order(42);
first.subscribe("tablet", () => {
throw new Error("printer jammed");
});
first.subscribe("customer", say("customer"));
first.subscribe("rider", say("rider"));
console.log(" failures:", first.setStatus("accepted").join("; ") || "none");
console.log("2. edits during a notification");
const second = new Order(43);
const subs = {};
subs.customer = second.subscribe("customer", (e) => {
console.log(` customer: ${e.newStatus}`);
subs.rider.cancel(); // the rider is removed before its turn
if (!subs.survey) subs.survey = second.subscribe("survey", say("survey")); // added mid-round
});
subs.rider = second.subscribe("rider", say("rider"));
second.setStatus("accepted"); // the rider is skipped, the survey is not heard yet
console.log(" subscribed now:", second.names());
second.setStatus("picked_up"); // the survey hears this one
console.log("3. a screen that was closed but never unsubscribed");
const third = new Order(44);
const screen = third.subscribe("order screen", say("order screen"));
third.setStatus("accepted");
console.log(" screen closed, subscribed:", third.names());
screen.cancel();
console.log(" after cancel, subscribed:", third.names());1. a failing listener
customer: accepted
rider: accepted
failures: tablet: printer jammed
2. edits during a notification
customer: accepted
subscribed now: customer, survey
customer: picked_up
survey: picked_up
3. a screen that was closed but never unsubscribed
order screen: accepted
screen closed, subscribed: order screen
after cancel, subscribed: nobodyThe hardened order catches each listener's error, records it, and carries on, so one jammed tablet never decides who else hears the event.
A failing listener. The tablet throws, and in the first version nothing after it would ever hear the event. Here the loop catches the error per listener, records it, and carries on: the customer and the rider still hear accepted, and set_status returns the failure. Swallowing it silently would be as bad: record it, log it or count it. The pub-sub logger tests exactly this with a sink that raises "disk full" (lld-08).
Notification order. This code calls listeners in the order they subscribed, and it says so. The pattern itself promises nothing: a hash-based collection, another library's event emitter or a message broker may deliver in any order, so no listener may rely on running before or after another. If one needs the result of another, that is a dependency: make it explicit instead of hoping for an order.
Re-entrancy. A listener may change the list while the loop runs. In section 2, the customer listener cancels the rider and adds a survey, in the middle of a notification. The rules this code enforces are small and written down: the loop runs over a snapshot, so adding a listener never disturbs it; a listener added during a round first hears the next event (the survey is silent for accepted); and a listener cancelled during a round does not hear that round's event if its turn hasn't come (the rider never prints). Without the snapshot, a collection changed under a loop may fail or skip an item. This code does not handle a second kind of re-entrancy: a listener that changes the order's status again inside its callback. That starts a nested round, and listeners later in the outer round hear the newer event before the older one. Forbid it, or queue the change and run it after the current round.
Unsubscribe, and the lapsed listener. In section 3 a screen is closed but never unsubscribed. The order still holds it, still calls it, and, because the order references it, a garbage collector can't free it. That is the lapsed listener leak, and it is the most common real bug with this pattern: an order that lives for hours keeps a reference to every screen that ever watched it. The fix is a rule: whatever subscribes must unsubscribe when it goes away. Here subscribe returns a handle with cancel(), safe to call twice and from inside a notification.
Weak references are the second line of defence, and each language does them differently:
| Language | The tool | The catch |
|---|---|---|
| Python | weakref.WeakMethod for a bound method, or a WeakSet of listener objects |
A plain weakref.ref to a bound method is dead at once: the method object is made fresh on each access. The subject must drop the dead entries |
| Java | WeakReference to the listener object |
A weak reference to a lambda or a method reference such as screen::onStatus is cleared at the next collection, because nothing else holds that lambda. When the collector clears it is up to the JVM, so it limits a leak; it never guarantees correctness |
| C++ | std::weak_ptr to a listener owned by a shared_ptr, checked with lock() before each call |
Deterministic: it expires the moment the last shared_ptr goes. The subject must skip and erase expired entries |
| JavaScript | WeakRef to the listener object |
The target survives at least until the current job ends; after that, whether and when it is collected is up to the engine, and a FinalizationRegistry callback may never run, so never rely on one to unsubscribe. In the DOM, addEventListener accepts an AbortSignal that removes the listener when aborted |
The trap is the same in Python, Java and JavaScript: hold a freshly made bound method, lambda or closure weakly, and it disappears even though the screen is still open, because nothing else refers to it. Hold the listener object weakly and call its method.
Synchronous or asynchronous. The loop above is synchronous: set_status returns only after every listener has finished, on the caller's thread. That makes order and errors easy to reason about, and it means a slow listener slows the caller and every listener after it. If a listener can be slow (a network call, a printer), do not run it inside the loop: hand the event to a queue and let a worker call the listener, as the logger's AsyncSink does. And if your subject has a lock, notify after releasing it: the elevator, snake and ladder, meeting rooms and auction lessons all do, so that a slow listener can't freeze the building.
A listener that never says goodbye
A support dashboard subscribes to every order and is never unsubscribed. Staff close the dashboard at 6 pm. What happens to the orders it was watching?
One strong answer
Each order still holds the dashboard and still calls it. Nothing throws, so nothing looks wrong: the dashboard object can't be freed, it does work for a screen nobody is looking at, and the count grows with every order and every day. The fix is for the dashboard to cancel its subscriptions when it closes. A weak reference would limit the damage, but "unsubscribe on teardown" is the rule; weak references are the net under it.
A listener that is closed but never unsubscribed is still held and still called, and it can never be freed. Whatever subscribes must unsubscribe.
Observer and a message broker#
"Observer" and "pub/sub" are often used as one word, and in an interview you should be able to say where one ends. Observer is a call inside one process: the subject holds direct references to its listeners and calls them. A message broker (Week 3 of the course) is a separate system between processes: publishers and consumers know a topic, not each other.
| Observer (in one process) | Message broker | |
|---|---|---|
| Who knows whom | The subject holds its listeners | Neither side holds the other; both know a topic |
| Delivery | A function call, now, on the caller's thread | The broker stores the message and delivers it later |
| If the listener is down | It throws, or it isn't subscribed any more | The message waits for the consumer |
| Retries and duplicates | None: a call is made once | Common: handlers must be safe to run twice (see idempotency) |
| Ordering | Whatever your loop promises | Whatever the broker promises, usually per queue or partition |
| Reach | Listeners in this program | Other services, other machines, other languages |
The two meet at the edge of a service. Inside the delivery service, the order announces a change to in-process listeners (a cache, a metrics counter), and one of them writes the event to an outbox that a broker carries to the email and analytics services (Week 3's queues, streams and outbox cover that side). If the listeners are in another process, "just subscribe" is the wrong tool.
Observer and its look-alikes#
| Pattern | Looks like Observer because | The difference |
|---|---|---|
| A single callback | something is called when an event happens | One receiver, passed in: a plain function argument. Observer is the same with a list and subscribe / unsubscribe. |
| Mediator (dp-23) | objects react to each other's changes | A mediator coordinates many peers that all talk to each other. Observer is one subject and many listeners, in one direction. |
| Chain of Responsibility (dp-21) | a request goes to several handlers | A chain passes one request along and a handler may stop it. Every observer hears the event, and none can stop the others. |
| Strategy (dp-16) | the subject calls an object behind an interface | A strategy is one object that does the subject's job. Observers are many objects that react to what the subject did. |
When not to use it#
An observer list is indirection: the reader of set_status can no longer see what happens next, only that "somebody might". For a rule that must always happen, it's the wrong tool.
- There is exactly one recipient and it is part of the design. If the order must always charge the card, call the payment code. Observers are for effects that may be added and removed, not for steps that must happen.
- The steps depend on each other. "Reserve stock, then charge, then notify" is a sequence in one method (a facade), not three listeners hoping to run in order.
- The listeners live in other processes. Use a broker, with its stored messages and retries, not a list of function pointers.
- A change must be rolled back if a listener fails. Listeners that can veto belong in the main code path, not behind an announcement.
Where it shows up in the case studies#
Seven of the 22 low-level design rounds use Observer in their own code, and three more raise it as a follow-up. Each row is a subject that changes, and a list of those who want to hear:
| Case study | The subject and its listeners | What is told |
|---|---|---|
| LRU cache | on_evict callback (light: one receiver) |
which entry left |
| Elevator | listeners on the controller |
floor, event: displays, dashboard, logger |
| Pub-sub logger | LogBus with subscribe / unsubscribe |
each log record, filtered by level and prefix |
| Snake and ladder | Game.listeners |
each move line: console, phone UI, replay log |
| Library | Notifier |
email, SMS and push for the same events |
| Meeting rooms | Notifier / Outbox (light) |
booking events: email, chat, calendar sync |
| Auction | AuctionListener, AuctionEvent |
outbid alerts, the live price, the audit log |
Follow-ups elsewhere: a display board per entrance in the parking lot (spot_taken and spot_freed), remote stock and cash reports in the vending machine, and a low-notes event in the ATM. Notice the rule that repeats in the elevator, snake and ladder, meeting-room and auction rounds: listeners are called after the lock is released.
In the interview#
Say the pattern with the reason, in one breath: "The order's status is what the screens care about, so I'll make it a subject: Order keeps a list of listeners and calls on_status(event) on each, so a new recipient is a new listener and Order is never edited. I'll notify after releasing the lock, and isolate a failing listener from the rest." Naming the pattern is the first half of the answer; naming what can go wrong is the second.
Then expect the follow-ups:
- "A display board at each entrance." Observer: the lot publishes
spot_takenandspot_freed, and each board subscribes. The board never pollsavailability(). - "One listener is slow." The loop is synchronous, so it slows the caller and everyone after it. Call it from a worker through a queue, and decide what to do when the queue is full (the logger's rule: drop, don't block).
- "One listener throws." Catch per listener, record the failure, carry on. Decide whether failures are reported to the caller, logged or counted.
- "A listener unsubscribes itself inside its callback." Safe because the loop runs over a snapshot and skips cancelled entries; say that it does not hear the rest of that round.
- "In which order do they run?" In the order they subscribed, because this code says so. The pattern doesn't promise it, so no listener may depend on it.
- "It has to work across services." That is a message broker: stored messages, retries, handlers safe to run twice. Observer stays inside the service.
Common mistakes#
- No way to unsubscribe, so a closed screen is still held and still called.
- Changing the list while looping over it. Loop over a copy, and decide what a cancelled or new listener hears this round.
- Notifying while holding a lock, so a slow listener blocks everyone who needs the lock.
- One exception stops the rest. One failing listener must not decide who else hears.
- Relying on the order of listeners that the code never promised.
- A listener that changes the subject inside its callback, with no plan for the nested round.
- Using Observer for a step that must always happen. If skipping it would be a bug, call it directly.
Observer
Q1/4 The delivery app needs a fourth recipient, an SMS service. In the observer design, what is the right change?
Answer & explanation
Answer: B
A new recipient is a new listener on the list. Order and the other listeners are not edited. That is what the interface and the list are for.
Q2/4 A support dashboard subscribes to every order and is closed at 6 pm without unsubscribing. What happens?
Answer & explanation
Answer: C
The subject's list is a reference, and nothing removes it. The dashboard keeps being called and can't be garbage collected: the lapsed listener leak. Unsubscribing on teardown is the fix.
Q3/4 The tablet's listener throws an error on its turn. The notify loop has no try and catch. Who is affected?
Answer & explanation
Answer: C
The exception leaves the loop, so later listeners are never called, and the caller that changed the status gets the error. Catching per listener and recording the failure keeps one broken screen from deciding who else hears.
Q4/4 The order service and the email service run on different machines. What fits?
Answer & explanation
Answer: D
An observer list is function calls inside one process. Between machines you need stored messages, retries and idempotent handlers: that's a broker's job.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


