Observer
Observer lets an object announce events to any number of subscribers without knowing who they are, so new reactions never require editing it. This page makes Pedal's ride service publish a ride-ended event to billing, the map, rebalancing and rewards, and shows what slow or failing observers do to it.
1. The pain
When a Pedal ride ends, several parts of the system care: billing charges the rider, the map updates the station's bike count, the rebalancing team checks whether the station is filling up. The first ride service called each of them by name at the end of end_ride().
So the ride service imported billing, map and rebalancing code, and its tests needed all three. When the rewards team wanted points for every ride, they had to edit the ride service, which another team owns. Every new interest in "a ride ended" meant changing the one class that should least need to change.
2. The idea in one picture
Observer lets an object (the subject) keep a list of interested objects (observers) and notify all of them when something happens, without knowing what they are. Rides offers subscribe() and, when a ride ends, calls every subscriber with a RideEnded event. Billing, the map, rebalancing and rewards each subscribe themselves.
In this simple form, observers run one after another inside end_ride(). A slow observer slows the ride service down; one that throws can stop the rest. Section 6 shows both. At larger scale the same idea becomes an event bus or a message broker, where observers run separately (see the Styles track's event-driven page).
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Subject | Keeps the list of observers; notifies them when something happens. | Rides with subscribe() and end_ride() |
| Observer | The interface observers implement (here, a function taking the event). | Listener = Callable[[RideEnded], str] |
| Concrete observer | Reacts to the event in its own way. | Billing, StationMap, Rebalancing, Rewards |
| Event | What happened, with the data observers need. | RideEnded(ride_id, rider, station, minutes) |
4. Before and after
The ride service both ways (Python sketch; the full program is in section 5).
Without the pattern
class Rides:
def end_ride(self, ride):
...
self.billing.charge(ride)
self.station_map.add_bike(ride.station)
self.rebalancing.check(ride.station)
self.rewards.add_points(ride) # rewards team
# edited our classWith it
class Rides:
def subscribe(self, fn):
self.listeners.append(fn)
def end_ride(self, event):
for fn in self.listeners:
fn(event)
# rewards team, in their own code:
rides.subscribe(Rewards().on_ride_ended)| The next change | Without the pattern | With it |
|---|---|---|
| Rewards want to react to rides | Edit the ride service | Subscribe from the rewards code |
| Turn off the map during a migration | Comment out a line in the ride service | Unsubscribe |
| Test end_ride() | Build billing, map and rebalancing first | Subscribe a fake that records events |
The subject depends on one interface instead of on every team that cares about rides.
5. The code
Rides keeps a list of listener functions and calls each when a ride ends. Rewards subscribes after two rides and sees only the third. Rebalancing keeps its own counts, so it doesn't depend on the map being notified first.
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
@dataclass
class RideEnded:
ride_id: str
rider: str
station: str
minutes: int
Listener = Callable[[RideEnded], str]
# ---- The subject: announces what happened. It knows nothing about who is listening. ----
class Rides:
def __init__(self) -> None:
self.listeners: list[Listener] = []
def subscribe(self, fn: Listener) -> None:
self.listeners.append(fn)
def unsubscribe(self, fn: Listener) -> None:
self.listeners.remove(fn)
def end_ride(self, event: RideEnded) -> list[str]:
lines = [f"{event.ride_id} ended at {event.station} after {event.minutes} min"]
lines += [f" {fn(event)}" for fn in self.listeners]
return lines
# ---- Observers: each reacts in its own way. ----
class Billing:
def on_ride_ended(self, e: RideEnded) -> str:
return f"billing: charged {e.rider} {100 + e.minutes * 10}p"
class StationMap:
def __init__(self) -> None:
self.bikes = {"S-31": 3, "S-12": 7}
def on_ride_ended(self, e: RideEnded) -> str:
self.bikes[e.station] += 1
return f"map: {e.station} now has {self.bikes[e.station]} bikes"
class Rebalancing:
"""Keeps its own counts: it must not depend on the map being notified first."""
def __init__(self) -> None:
self.bikes = {"S-31": 3, "S-12": 7}
self.docks = {"S-31": 6, "S-12": 12}
def on_ride_ended(self, e: RideEnded) -> str:
self.bikes[e.station] += 1
n, cap = self.bikes[e.station], self.docks[e.station]
if n >= cap - 1:
return f"rebalancing: {e.station} is nearly full ({n} of {cap} docks), schedule a pick-up"
return f"rebalancing: {e.station} fine ({n} of {cap} docks)"
class Rewards:
def on_ride_ended(self, e: RideEnded) -> str:
return f"rewards: {e.rider} earned {e.minutes // 2} points"
if __name__ == "__main__":
rides, station_map = Rides(), StationMap()
rides.subscribe(Billing().on_ride_ended)
rides.subscribe(station_map.on_ride_ended)
rides.subscribe(Rebalancing().on_ride_ended)
print("\n".join(rides.end_ride(RideEnded("R-1", "mia", "S-31", 12))))
print("\n".join(rides.end_ride(RideEnded("R-2", "leo", "S-31", 10))))
rides.subscribe(Rewards().on_ride_ended) # a new listener; Rides didn't change
print("rewards subscribed")
print("\n".join(rides.end_ride(RideEnded("R-3", "leo", "S-12", 22))))
interface RideEnded { rideId: string; rider: string; station: string; minutes: number; }
type Listener = (e: RideEnded) => string;
// ---- The subject: announces what happened. It knows nothing about who is listening. ----
class Rides {
private readonly listeners: Listener[] = [];
subscribe(fn: Listener): void { this.listeners.push(fn); }
unsubscribe(fn: Listener): void { this.listeners.splice(this.listeners.indexOf(fn), 1); }
endRide(e: RideEnded): string[] {
return [`${e.rideId} ended at ${e.station} after ${e.minutes} min`, ...this.listeners.map((fn) => ` ${fn(e)}`)];
}
}
// ---- Observers: each reacts in its own way. ----
class Billing {
onRideEnded = (e: RideEnded): string => `billing: charged ${e.rider} ${100 + e.minutes * 10}p`;
}
class StationMap {
readonly bikes: Record<string, number> = { "S-31": 3, "S-12": 7 };
onRideEnded = (e: RideEnded): string => { this.bikes[e.station] += 1; return `map: ${e.station} now has ${this.bikes[e.station]} bikes`; };
}
class Rebalancing { // keeps its own counts: it must not depend on the map being notified first
private readonly bikes: Record<string, number> = { "S-31": 3, "S-12": 7 };
private readonly docks: Record<string, number> = { "S-31": 6, "S-12": 12 };
onRideEnded = (e: RideEnded): string => {
const n = ++this.bikes[e.station], cap = this.docks[e.station];
return n >= cap - 1 ? `rebalancing: ${e.station} is nearly full (${n} of ${cap} docks), schedule a pick-up` : `rebalancing: ${e.station} fine (${n} of ${cap} docks)`;
};
}
class Rewards {
onRideEnded = (e: RideEnded): string => `rewards: ${e.rider} earned ${Math.floor(e.minutes / 2)} points`;
}
const rides = new Rides(), stationMap = new StationMap();
rides.subscribe(new Billing().onRideEnded);
rides.subscribe(stationMap.onRideEnded);
rides.subscribe(new Rebalancing().onRideEnded);
console.log(rides.endRide({ rideId: "R-1", rider: "mia", station: "S-31", minutes: 12 }).join("\n"));
console.log(rides.endRide({ rideId: "R-2", rider: "leo", station: "S-31", minutes: 10 }).join("\n"));
rides.subscribe(new Rewards().onRideEnded); // a new listener; Rides didn't change
console.log("rewards subscribed");
console.log(rides.endRide({ rideId: "R-3", rider: "leo", station: "S-12", minutes: 22 }).join("\n"));import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class Main {
record RideEnded(String rideId, String rider, String station, int minutes) {}
// ---- The subject: announces what happened. It knows nothing about who is listening. ----
static class Rides {
private final List<Function<RideEnded, String>> listeners = new ArrayList<>();
void subscribe(Function<RideEnded, String> fn) { listeners.add(fn); }
void unsubscribe(Function<RideEnded, String> fn) { listeners.remove(fn); }
List<String> endRide(RideEnded e) {
List<String> lines = new ArrayList<>();
lines.add(e.rideId() + " ended at " + e.station() + " after " + e.minutes() + " min");
for (Function<RideEnded, String> fn : listeners) lines.add(" " + fn.apply(e));
return lines;
}
}
// ---- Observers: each reacts in its own way. ----
static class Billing {
String onRideEnded(RideEnded e) { return "billing: charged " + e.rider() + " " + (100 + e.minutes() * 10) + "p"; }
}
static class StationMap {
final Map<String, Integer> bikes = new HashMap<>(Map.of("S-31", 3, "S-12", 7));
String onRideEnded(RideEnded e) {
bikes.merge(e.station(), 1, Integer::sum);
return "map: " + e.station() + " now has " + bikes.get(e.station()) + " bikes";
}
}
static class Rebalancing { // keeps its own counts: it must not depend on the map being notified first
private final Map<String, Integer> bikes = new HashMap<>(Map.of("S-31", 3, "S-12", 7));
private final Map<String, Integer> docks = Map.of("S-31", 6, "S-12", 12);
String onRideEnded(RideEnded e) {
int n = bikes.merge(e.station(), 1, Integer::sum), cap = docks.get(e.station());
if (n >= cap - 1) return "rebalancing: " + e.station() + " is nearly full (" + n + " of " + cap + " docks), schedule a pick-up";
return "rebalancing: " + e.station() + " fine (" + n + " of " + cap + " docks)";
}
}
static class Rewards {
String onRideEnded(RideEnded e) { return "rewards: " + e.rider() + " earned " + e.minutes() / 2 + " points"; }
}
public static void main(String[] args) {
Rides rides = new Rides();
StationMap stationMap = new StationMap();
rides.subscribe(new Billing()::onRideEnded);
rides.subscribe(stationMap::onRideEnded);
rides.subscribe(new Rebalancing()::onRideEnded);
System.out.println(String.join("\n", rides.endRide(new RideEnded("R-1", "mia", "S-31", 12))));
System.out.println(String.join("\n", rides.endRide(new RideEnded("R-2", "leo", "S-31", 10))));
rides.subscribe(new Rewards()::onRideEnded); // a new listener; Rides didn't change
System.out.println("rewards subscribed");
System.out.println(String.join("\n", rides.endRide(new RideEnded("R-3", "leo", "S-12", 22))));
}
}Output (the same in all three languages)
R-1 ended at S-31 after 12 min billing: charged mia 220p map: S-31 now has 4 bikes rebalancing: S-31 fine (4 of 6 docks) R-2 ended at S-31 after 10 min billing: charged leo 200p map: S-31 now has 5 bikes rebalancing: S-31 is nearly full (5 of 6 docks), schedule a pick-up rewards subscribed R-3 ended at S-12 after 22 min billing: charged leo 320p map: S-12 now has 8 bikes rebalancing: S-12 fine (8 of 12 docks) rewards: leo earned 11 points
6. Try it
End a ride
Choose who is subscribed, then end a ride. Observers here run one after another, inside end_ride().
7. When not to use it
What you get
- Loose coupling: the subject knows only the observer interface.
- Open for new reactions: subscribe without touching the subject.
- Dynamic: subscribe and unsubscribe at runtime.
- Easy testing: subscribe a recorder.
What you pay
- Hidden flow: reading
end_ride()doesn't tell you what happens next. - Slow or failing observers hurt the subject: when they run in its call.
- Order is unspecified: observers that depend on each other break.
- Leaks: observers that never unsubscribe keep objects alive.
| If… | Use instead |
|---|---|
| Observers are in other services, or must not slow the subject | An event bus or message broker (Styles 10: event-driven) |
| The objects need to coordinate with each other, not just react | 19 Mediator |
| Exactly one handler should act, or the request can be stopped | 17 Chain of responsibility |
| There is one fixed reaction that never changes | A direct call is clearer |
8. How it goes wrong
end_ride() makes every rider wait for it. Queue slow work.9. Remember it and say it
How this hook works: what “Publish, don't phone” means
What the slogan means, phrase by phrase
- “Publish”
- The subject notifies all observers.
- “don't phone”
- It doesn't call each one by name.
- “whoever subscribed”
- Observers register themselves.
Why this picture fits: A newsletter grows its readership without its editor learning the readers' names.
The story → the concept
| The newsletter | The code |
|---|---|
| The newsletter | Rides |
| An issue | A RideEnded event |
| Readers | Billing, map, rebalancing, rewards |
| Signing up | subscribe() |
Watch it on an example
- Ride R-2 ends at S-31.
- Rides notifies every subscriber: billing charges, the map adds a bike, rebalancing flags the station as nearly full.
- Rewards subscribes later; R-3 reaches it too. Rides didn't change.
The trap it saves you from
A subject that calls every interested party by name, so each new party edits it.
It still works when…
Yes: UI event listeners, model-view frameworks, webhooks, and reactive streams.
In an interview or code review
- Say who knows whom: "Rides publishes
RideEnded; billing, map, rebalancing and rewards subscribe. Rides knows none of them." - Raise the risks yourself: synchronous observers slow the subject and can fail it; isolate errors and queue slow work.
- Scale it up: across services this becomes an event bus or broker, with at-least-once delivery and idempotent consumers.
- Contrast with mediator: observers react independently; a mediator coordinates objects that must work together.
10. Check yourself
What does the subject know about its observers?
The rewards team wants to react to rides. What changes in Rides?
An observer throws inside a simple notify loop. What happens?
Rebalancing reads the map's count. What's the risk?
When should observers become a message broker?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture styles 10: Event-driven | Observer at the scale of services. |
| 19 Mediator | The coordinating cousin. |
MDN: addEventListener | The browser's built-in observer. |
| Wikipedia: Observer pattern | Push versus pull, and history. |