InterviewKata
Design patterns · 18 of 26 · Objects talking

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

A newsletter doesn't know its readers personally. It publishes; whoever subscribed gets a copy and does what they like with it. New readers sign up without the editor changing anything, and readers can unsubscribe at any time.

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

RoleIts jobIn Pedal
SubjectKeeps the list of observers; notifies them when something happens.Rides with subscribe() and end_ride()
ObserverThe interface observers implement (here, a function taking the event).Listener = Callable[[RideEnded], str]
Concrete observerReacts to the event in its own way.Billing, StationMap, Rebalancing, Rewards
EventWhat 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 class

With 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 changeWithout the patternWith it
Rewards want to react to ridesEdit the ride serviceSubscribe from the rewards code
Turn off the map during a migrationComment out a line in the ride serviceUnsubscribe
Test end_ride()Build billing, map and rebalancing firstSubscribe 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 subjectAn event bus or message broker (Styles 10: event-driven)
    The objects need to coordinate with each other, not just react19 Mediator
    Exactly one handler should act, or the request can be stopped17 Chain of responsibility
    There is one fixed reaction that never changesA direct call is clearer

    8. How it goes wrong

    Observers that depend on each other's orderIf rebalancing reads the map's count, it breaks when the map is notified second. Each observer should work from the event (or its own state), as in the code above.
    One failure stops the restIn a simple loop, an exception in one observer skips the ones after it. Catch per observer, or deliver asynchronously.
    Slow work in the subject's callSending an email inside end_ride() makes every rider wait for it. Queue slow work.
    Forgetting to unsubscribeA screen that subscribes and is closed stays in memory, still reacting. Unsubscribe when the observer's life ends.

    9. Remember it and say it

    Memory hook: "Publish, don't phone"The subject announces; whoever subscribed reacts. It never needs to know who they are.
    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 newsletterThe code
    The newsletterRides
    An issueA RideEnded event
    ReadersBilling, map, rebalancing, rewards
    Signing upsubscribe()

    Watch it on an example

    1. Ride R-2 ends at S-31.
    2. Rides notifies every subscriber: billing charges, the map adds a bike, rebalancing flags the station as nearly full.
    3. 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

    10. Check yourself

    What does the subject know about its observers?

    That's the decoupling.

    The rewards team wants to react to rides. What changes in Rides?

    New reactions are additive.

    An observer throws inside a simple notify loop. What happens?

    Catch per observer or deliver asynchronously.

    Rebalancing reads the map's count. What's the risk?

    Observers shouldn't depend on each other's order.

    When should observers become a message broker?

    The broker delivers asynchronously.

    11. Go deeper

    ResourceWhy
    Architecture styles 10: Event-drivenObserver at the scale of services.
    19 MediatorThe coordinating cousin.
    MDN: addEventListenerThe browser's built-in observer.
    Wikipedia: Observer patternPush versus pull, and history.