Skip to content

14. Reacting to Change: Observer and State

Both patterns in this chapter are about behavior that changes over time — but along opposite axes. One pushes change outward; the other pulls behavior inward:

Observer decouples who reacts when something happens — a one-to-many broadcast, triggered by an external event. State decouples how an object behaves now — one object that acts differently in each phase of its lifecycle.

Observer answers “something happened — who needs to know?” State answers “what am I right now, and what may I do in this phase?”

checkout-lite has two pains that both come from change, pulling in different directions.

First, the swelling flow. The place_order function from Chapter 11 started as one clean coordinator. Then every team bolted their reaction onto it — email the receipt, decrement inventory, ping analytics, enqueue a fraud check — until the function knows about four subsystems that have nothing to do with the act of placing an order:

def place_order(order: Order) -> None:
save(order)
send_receipt(order) # marketing's
decrement_inventory(order) # warehouse's
record_analytics(order) # data team's
enqueue_fraud_check(order) # risk's — and tomorrow, one more...

Every new reaction edits the core flow, and a function that should say “place an order” instead enumerates everyone who cares.

Second, the lifecycle thicket. An Order moves through phases — cart, paid, shipped, delivered, cancelled — and the methods that move it are riddled with status checks:

def ship(order: Order) -> None:
if order.status == "paid":
order.status = "shipped"
elif order.status == "cart":
raise ValueError("can't ship an unpaid order")
elif order.status == "cancelled":
raise ValueError("can't ship a cancelled order")
# ...and cancel(), refund(), pay() each repeat this same fragile ladder

The transition rules are smeared across every method, no single place says which moves are legal, and nothing structurally stops you from shipping a cancelled order — only your vigilance does.

Observer — broadcast a change to whoever cares

Section titled “Observer — broadcast a change to whoever cares”

The Observer pattern lets an object (the subject) maintain a list of dependents (observers) and notify them automatically when something happens. The subject knows that it must announce, never who is listening — observers subscribe and unsubscribe on their own. This is publish/subscribe at codebase scale.

classDiagram
    class OrderEvents {
        +subscribe(fn)
        +publish(event)
    }
    class send_receipt
    class update_inventory
    class record_analytics
    OrderEvents o--> send_receipt : notifies
    OrderEvents o--> update_inventory : notifies
    OrderEvents o--> record_analytics : notifies

The flow publishes one OrderPlaced event; the subscribers fan out. Adding a fourth reaction is one subscribe() call and zero edits to place_order — the open-closed principle (Chapter 8) applied to reactions.

State — be a different thing in each phase

Section titled “State — be a different thing in each phase”

The State pattern lets an object alter its behavior when its internal state changes, by delegating the behavior to a separate state object. Each state is its own class that knows two things: what it does, and which transitions out of it are legal. The context (the Order) just forwards to its current state. The natural design artifact is a state diagram:

stateDiagram-v2
    [*] --> Cart
    Cart --> Paid : pay
    Cart --> Cancelled : cancel
    Paid --> Shipped : ship
    Paid --> Cancelled : cancel
    Shipped --> [*]
    Cancelled --> [*]

Every arrow in that diagram is a legal transition; every missing arrow is one the design should forbid. The State pattern makes the diagram executable: a transition that isn’t drawn simply isn’t a method on that state, so it can’t happen.

These two are rarely confused for each other — the value is seeing them as two answers to “behavior that changes,” chosen by what changes:

ObserverState
TriggerAn external event happenedThe object’s own lifecycle moved
ShapeOne-to-many broadcastOne object, many behaviors
DecouplesWho reacts to a changeHow an object behaves now
One-liner”Tell whoever cares""Be a different thing in each phase”

If the question is “who needs to know this happened?”, reach for Observer. If it’s “this object should behave differently depending on what phase it’s in,” reach for State.

The tangle is place_order from The Itch: the flow hardcodes every reaction.

def place_order(order: Order) -> None:
save(order)
send_receipt(order) # every reaction is wired into the core flow,
decrement_inventory(order) # so each new one edits this function
record_analytics(order)

The flow publishes an event; reactions subscribe. place_order no longer names a single subscriber, and a new reaction never touches it.

from collections.abc import Callable
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderPlaced:
order_id: str
total: float
Subscriber = Callable[[OrderPlaced], None]
class OrderEvents: # the subject
def __init__(self) -> None:
self._subscribers: list[Subscriber] = []
def subscribe(self, subscriber: Subscriber) -> None:
self._subscribers.append(subscriber)
def publish(self, event: OrderPlaced) -> None:
for subscriber in self._subscribers:
subscriber(event)
def place_order(order_id: str, total: float, events: OrderEvents) -> OrderPlaced:
event = OrderPlaced(order_id, total)
events.publish(event) # announce; who listens isn't our concern
return event
# wiring, done once at the edge — not inside place_order:
events = OrderEvents()
events.subscribe(send_receipt)
events.subscribe(update_inventory)

The tangle is the status-string ladder from The Itch, repeated in every method that moves the order.

def ship(order: Order) -> None:
if order.status == "paid":
order.status = "shipped"
elif order.status == "cart":
raise ValueError("can't ship an unpaid order")
# ...the same ladder reappears in pay(), cancel(), refund()

Each state is a class that allows only its legal transitions. The base state makes every move illegal by default, so a state enables a transition simply by overriding it — and an unlisted move raises automatically.

class IllegalTransition(Exception): ...
class OrderState: # base: every move illegal by default
name = "base"
def pay(self, order: "Order") -> None:
raise IllegalTransition(f"cannot pay from {self.name}")
def ship(self, order: "Order") -> None:
raise IllegalTransition(f"cannot ship from {self.name}")
def cancel(self, order: "Order") -> None:
raise IllegalTransition(f"cannot cancel from {self.name}")
class Cart(OrderState):
name = "cart"
def pay(self, order: "Order") -> None:
order.state = Paid() # the only legal moves from a cart:
def cancel(self, order: "Order") -> None:
order.state = Cancelled() # pay or cancel
class Paid(OrderState):
name = "paid"
def ship(self, order: "Order") -> None:
order.state = Shipped()
def cancel(self, order: "Order") -> None:
order.state = Cancelled()
class Shipped(OrderState):
name = "shipped" # terminal: nothing overridden → all raise
class Cancelled(OrderState):
name = "cancelled" # terminal
class Order: # the context: delegates to its state
def __init__(self) -> None:
self.state: OrderState = Cart()
def pay(self) -> None: self.state.pay(self)
def ship(self) -> None: self.state.ship(self)
def cancel(self) -> None: self.state.cancel(self)
@property
def status(self) -> str: return self.state.name

Shipping a cart no longer needs a guard clause — Cart simply has no ship, so it inherits the base’s refusal. The legal transitions are the overridden methods; the state diagram and the code can’t drift apart. The full code, with tests proving the broadcast reaches every subscriber and the lifecycle rejects illegal moves, is in examples/ch14/py/ and examples/ch14/ts/.

Both patterns have a heavyweight classical form and a much lighter idiomatic one — and in both languages the lightweight form is what you should usually reach for.

Observer is just a list of callables. Python has first-class functions, so you almost never need an Observer base class with an update() method — a subscriber is any Callable, and the registry is a plain list. That is exactly the examples/ch14/py design: subscribe appends a function, publish calls each one.

State has an Enum + match form. When states carry no behavior of their own and you just need legal transitions, a class per state can be more ceremony than the problem deserves. Model the states as an Enum and the transition table as a match:

from enum import Enum, auto
class Status(Enum):
CART = auto(); PAID = auto(); SHIPPED = auto(); CANCELLED = auto()
def ship(status: Status) -> Status:
match status:
case Status.PAID:
return Status.SHIPPED
case _:
raise IllegalTransition(f"cannot ship from {status.name}")

Reach for the class-per-state form when each state owns real behavior or data (a Paid state that holds the transaction id, a Shipped state that computes a tracking ETA). Reach for Enum + match when the states are just labels and only the transitions vary.

An agent reaches for the heavyweight form of both patterns by reflex — a full EventBus framework for two listeners, a class per state for an on/off flag. It also tends to leave the old mechanism in place beside the new one. Your review job: check the weight, and check that the thing you replaced is actually gone.

You sayThe agent hears
”Make this Observer / publish-subscribe”A subject holds subscribers and notifies them; callers subscribe, the subject doesn’t know who
”Subscribers are just callables”No Observer base class — a list of functions (Python) / array of callbacks (TS)
“Model the lifecycle with the State pattern”One class per state, each owning its legal transitions; the context delegates
”Illegal transitions should be impossible”A move that isn’t legal from a state isn’t a method on it — it raises, not silently passes
”Use enum + match / a discriminated union instead”States are labels; a transition table, not a class hierarchy
”Don’t add an event bus for one listener”Keep the direct call; reach for Observer only when reactions are many
💬 Observer for a swelling flow

[place_order] hardcodes a growing list of side-effects. Refactor to the Observer pattern: the flow publishes one [OrderPlaced] event; each reaction becomes a subscriber registered at the edge. Subscribers are plain functions (a list of callables / array of callbacks) — no Observer base class. [place_order] must not name any individual subscriber after the refactor.

💬 State for a lifecycle thicket

The [Order] lifecycle is enforced by status-string if-ladders repeated across methods. Refactor to the State pattern: one class per state, a base state where every transition is illegal by default, and each state overriding only its legal moves. Illegal transitions must raise, not fall through. Delete the old status ladders — don’t leave them beside the new states.

💬 Right-size it first

Before coding: how many things react to [this event], and how many states does [this object] really have? If one known caller reacts, keep the direct call. If there are two stable states, keep a boolean and a guard. Answer in two sentences, then recommend Observer, State, or neither.

  • Observer: the subject holds a list of subscribers and doesn’t name any of them
  • Subscribers are plain functions unless one genuinely needs state/multiple methods
  • The published flow no longer hardcodes individual reactions
  • State: one state per class (or enum/union case); the context delegates, no status ladder left behind
  • Illegal transitions raise — they are not methods that quietly do nothing
  • Neither pattern was added below its threshold (one subscriber; two stable states)
  • The framework for two listeners. Asked for Observer, the agent writes an EventBus with topics, priorities, and async dispatch — for two synchronous reactions.
  • The double source of truth. The new states (or event) are added, but the old status ladder / inline side-effects survive alongside them. Check the old path is gone.
  • The class-per-state for a boolean. A StateOn/StateOff hierarchy where a flag and a guard would do.
  • Silent illegal transitions. A state “handles” a move it shouldn’t allow by doing nothing, instead of raising — the bug the State pattern was supposed to prevent.
  • Observer and State both model behavior that changes over time, on opposite axes: Observer broadcasts an event to whoever cares; State makes one object behave differently in each phase of its life.
  • Observer decouples who reacts — the subject holds subscribers and never names them, so a new reaction is one subscribe() and zero edits to the flow (open-closed for reactions).
  • State decouples how an object behaves now — each state owns its legal transitions, so the illegal ones become impossible rather than guarded against. A stateDiagram is its blueprint.
  • Both have a lightweight idiomatic form: a list/array of callables for Observer, and enum + match / a discriminated union for label-only State. Reach for classes only when subscribers or states carry real behavior.
  • Right-size both: no event bus for one listener; no class-per-state for a boolean.
  • Glossary terms added: Observer pattern · publish/subscribe (event) · State pattern · explicit state machine.