Behavioral Patterns II: Observer & State

Design Patterns

Chapter 8 · Behavioral Patterns II: Observer & State

Two more ways an object's behavior can vary — but this time neither one is about picking an algorithm. Observer lets a changing object automatically notify a whole list of interested parties, without knowing anything about who they are. State lets an object's own methods behave completely differently depending on what it currently is — with no if/elif chain checking that anywhere.

Observer: Automatic Notification on Change

A subject keeps a list of observers and notifies every one of them whenever something changes — each observer decides for itself what, if anything, to do with that notification.

class Stock: # the subject def __init__(self, symbol, price): self.symbol = symbol; self._price = price; self._observers = [] def attach(self, observer): self._observers.append(observer) def detach(self, observer): self._observers.remove(observer) def set_price(self, price): self._price = price self._notify() def _notify(self): for observer in self._observers: observer.update(self.symbol, self._price) class PriceLogger: # an observer — just records def __init__(self): self.log = [] def update(self, symbol, price): self.log.append((symbol, price)) class AlertService: # an observer — reacts conditionally def __init__(self, threshold): self.threshold = threshold; self.alerts = [] def update(self, symbol, price): if price > self.threshold: self.alerts.append(f'{symbol} exceeded {self.threshold}: now {price}')
Verified directly — one price change, two genuinely different reactions, with no coordination between the observers
With logger and alert (threshold 150) both attached to the same Stock('ACME', 100): calling set_price(120) adds an entry to logger.log but leaves alert.alerts empty (120 doesn't exceed 150). Calling set_price(160) adds a second entry to logger.log and triggers alert.alerts for the first time. Stock never checked what kind of observer it was talking to — it just called .update() on everything in its list.
Verified directly — detaching one observer stops only that observer's future updates
After stock.detach(logger), calling set_price(200) leaves logger.log completely unchanged (still 2 entries) — but alert, which was never detached, correctly receives the update and grows to 2 entries of its own. One observer stopped listening; the other kept listening; Stock's own code needed no change to support either outcome.

State: Behavior That Changes With the Object's Own Condition

An order behaves differently depending on whether it's pending, paid, shipped, or delivered — calling .ship() should succeed on a paid order and fail on a pending one. State moves each condition's own rules into its own small class, and lets the main object simply delegate to whichever one is currently active.

class PendingState: name = 'Pending' def pay(self, order): order.state = PaidState() def ship(self, order): raise Exception('Cannot ship an unpaid order') def deliver(self, order): raise Exception('Cannot deliver an unpaid order') class PaidState: name = 'Paid' def pay(self, order): raise Exception('Order already paid') def ship(self, order): order.state = ShippedState() def deliver(self, order): raise Exception('Cannot deliver before shipping') # ShippedState and DeliveredState follow the same shape — # each state class only allows the transitions that make sense from it class OrderContext: def __init__(self): self.state = PendingState() def pay(self): self.state.pay(self) def ship(self): self.state.ship(self) def deliver(self): self.state.deliver(self) def status(self): return self.state.name
Verified directly — the identical method call produces different behavior as the order's own state changes
On a fresh OrderContext() (status 'Pending'), calling .ship() correctly raises 'Cannot ship an unpaid order', and the status is confirmed still 'Pending' afterward — the blocked call left no trace. Calling .pay() then .ship() then .deliver() in sequence moves the status through 'Paid''Shipped''Delivered' — each step calling a differently-behaving state object, even though OrderContext.ship()'s own code (self.state.ship(self)) never changed.
Verified directly — an invalid transition from the final state is blocked the same way
Calling .pay() again on the now-'Delivered' order correctly raises 'Order already delivered', and the status is confirmed still 'Delivered' afterward — the exact same blocked-call-leaves-no-trace behavior verified earlier for the pending order, now demonstrated at the opposite end of the state sequence.
Where the if/elif chain would have lived instead
Without State, OrderContext.ship() would need its own if self.status == 'Paid': ... elif self.status == 'Pending': raise ... elif ... block — repeated inside pay(), ship(), and deliver() alike, with every new status requiring a new branch added to all three methods. Here, adding a new status means writing one new state class; none of OrderContext's three methods change at all.

Where This Connects

This chapter's findingWhat it connects to
Observer's subject calling an identical .update() on every unknown-typed observerThe same "call the same method, let the object decide what happens" idea Chapter 7's Strategy already relied on — Observer applies it to notification instead of algorithm selection
State's swappable self.state object, changed by the state objects themselvesStructurally close to Chapter 7's Strategy (composition, a swappable object) — but here the object being delegated to is the one deciding when to swap itself out, not an outside caller
Both patterns eliminating a growing if/elif chainClean Code, SOLID & Refactoring's own treatment of conditional complexity as a recognized code smell

Hands-On Exercises

Exercise 1

Add a third observer, MovingAverageTracker, that records every price it receives and can report the average of all prices seen so far. Attach it alongside this chapter's own logger and alert, send three price updates, and verify its average is correct.

📄 View solution
Exercise 2

Add a CancelledState to this chapter's own order system, reachable via a new cancel() method that's only allowed from PendingState or PaidState (not after shipping). Verify cancelling a pending order succeeds, and cancelling a shipped order is correctly blocked.

📄 View solution
Exercise 3

Explain, using this chapter's own two verified patterns, why Stock needing to know nothing about PriceLogger or AlertService's own internals is the same underlying idea as OrderContext needing to know nothing about what PendingState.ship() versus PaidState.ship() actually do.

📄 View solution

Chapter 8 Quick Reference

  • Observer: a subject notifies a list of registered observers automatically on change, without knowing their concrete types — verified: two observers reacted differently to the same price change, and detaching one stopped only its own future updates while the other kept receiving them
  • State: an object delegates its own method calls to a swappable "current state" object, so behavior changes with condition rather than through if/elif checks — verified: the identical .ship() call raised an exception from PendingState but transitioned correctly from PaidState, with a blocked call at both ends of the sequence leaving the status unchanged
  • Next chapter: Behavioral Patterns III — Command and Iterator