2.2.12 · D2Design Principles

Visual walkthrough — Design Patterns — Behavioral - Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

2,072 words9 min readBack to topic

Before any code, three plain words we will use the whole way:


Step 1 — The naive wiring: one object hard-calls the others

WHAT. Imagine a WeatherStation that reads a temperature. Three things must react: a PhoneDisplay, a Logger, and a Chart. The obvious first draft: when the temperature changes, the station calls each one by name.

class WeatherStation:
    def set_temp(self, t):
        self.temp = t
        phone.show(t)      # station must KNOW phone exists
        logger.write(t)    # ...and logger exists
        chart.plot(t)      # ...and chart exists, and their method names

WHY it looks fine. For three listeners it is only three lines — short and readable.

PICTURE. Look at figure 1. The amber WeatherStation box has three separate cyan arrows, each hard-drawn to a specific listener. Every arrowhead is a name the station had to memorise. The station is the hub of a wheel it built by hand.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

Term by term, the danger sits in the arrows themselves:

  • each arrow = one import / one method name the station is welded to,
  • erase a listener → you must edit the station,
  • add a listener → you must edit the station again.

Step 2 — Watch the coupling explode as listeners grow

WHAT. Now the product team adds a SmsAlert, a WebSocket, and a CsvExport. Each new listener means a new hard-coded line inside set_temp.

WHY this is the real problem. The cost of "add one listener" is not constant — it grows because every addition reopens and re-tests the station. In figure 2 the count of arrows leaving the station is the count of things that can break the station.

PICTURE. Figure 2 puts figure 1's clean 3 arrows beside a 6-arrow tangle. Notice the station box had to grow (more lines inside) even though its real job — reading temperature — never changed. That is the smell: unrelated change forced on unrelated code.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

Step 3 — The key move: invert the arrow into a subscription

WHAT. Instead of the station reaching out to each listener, we flip it: listeners reach in and register themselves. The station no longer knows who — only that it has "a list of things to call."

WHY this exact move. We want the cost of adding a listener to drop from "edit the station" to "edit nothing in the station." The only way to not-edit a caller when the callees change is to make the caller loop over a list it does not hardcode. The list is the whole trick.

PICTURE. Figure 3 redraws the wheel: the arrows now point from each listener toward the station's list, at subscribe time. The station's own outgoing arrow becomes a single dashed loop labelled "for each in list". One arrow replaces many.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

We give the two roles their permanent names:


Step 4 — The contract: everyone agrees on ONE button update

WHAT. For the station's loop to work, every listener must be callable the same way. So we demand: each observer exposes exactly update(data). The station calls that and nothing else.

WHY an interface here and not just "any function." The station loops blindly: for o in list: o.update(data). For this line to never crash, every o must have update. That shared promise is the interface — the thin agreed contract the parent note called the "soul" of behavioral patterns. It is what lets the station stay ignorant of concrete classes.

PICTURE. Figure 4 draws the interface as an amber horizontal bar labelled Observer: update(data). Below it hang three concrete boxes (PhoneDisplay, Logger, Chart), each with its own private behaviour behind the same button. The station above touches only the bar, never the boxes.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

Term by term, in o.update(data):

  • o = some observer — the station does not know which,
  • .update = the one button the contract guarantees,
  • data = the new state, pushed outward (the station does not wait for an answer).

Step 5 — Write the machine: subscribe, notify, loop

WHAT. Now the code writes itself from the picture.

class Subject:
    def __init__(self):
        self._observers = []              # the LIST from Step 3
    def subscribe(self, o):
        self._observers.append(o)         # listener reaches IN (Step 3 arrow)
    def unsubscribe(self, o):
        self._observers.remove(o)         # runtime opt-out
    def notify(self, data):
        for o in self._observers:         # the dashed loop of figure 3
            o.update(data)                # the ONE button of figure 4

WHY each line exists (mapped straight to a figure):

  • self._observers = [] → the list that made the arrows disappear (Step 3).
  • subscribe → the inward arrow; listeners self-register.
  • notify's for loop → the single dashed loop replacing many hard arrows.
  • o.update(data) → the amber contract bar of Step 4.

PICTURE. Figure 5 overlays this code on the wheel: each line is annotated with the figure-3/figure-4 element it implements. The station box now has a fixed size — adding listeners never touches it again.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

Step 6 — Edge case A: the empty list

WHAT. What happens the very first moment, before anyone subscribes?

WHY it matters. A pattern must survive its degenerate input. Here _observers is [].

PICTURE. Figure 6, left panel: the station with an empty list. The for loop over [] runs zero times and returns silently — no crash, no special-case if.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,
Subject().notify("x")   # loops 0 times → does nothing, no error

This is the beauty of looping over a list instead of hard calls: zero listeners is just the natural boundary, not a special branch.


Step 7 — Edge case B: unsubscribing during a notify

WHAT. A subtle trap. Suppose an observer's update decides to unsubscribe itself (or another) while notify is still looping over the very list being modified.

WHY this bites. Removing from a list while iterating it skips elements or raises errors in many languages. In figure 6's right panel, the loop pointer is mid-list when an entry vanishes — the pointer now lands on the wrong slot.

The fix — iterate over a copy:

def notify(self, data):
    for o in list(self._observers):   # snapshot; safe to mutate original
        o.update(data)

list(self._observers) builds a throwaway copy; observers may leave the real list freely, and the current broadcast still visits everyone who was present when it started.


Step 8 — Edge case C: a dead / broken observer

WHAT. One observer's update throws (its window was closed, its socket died). Naively, the exception kills the loop and the remaining observers never hear the news.

WHY. Fan-out must be robust: one broken listener should not silence the rest.

PICTURE. Figure 7 shows the loop with observer #2 marked with an amber ✗; without protection the arrow chain stops there. With a guard, the loop steps over it and reaches #3.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,
def notify(self, data):
    for o in list(self._observers):
        try:
            o.update(data)
        except Exception:
            pass          # or: log + auto-unsubscribe the dead one

The one-picture summary

WHAT. One figure that compresses all eight steps: the hard-wired tangle collapses through the list-and-contract into a clean star where the Subject knows only an interface, and listeners come and go at runtime.

Figure — Design Patterns — Behavioral -  Observer, Strategy, Command, Iterator, State, Template Method, Chain of Responsibility,

Reading the summary left → right:

  1. Tangle (Steps 1–2): station hardcodes every listener; cost of change is high.
  2. Insert the list + contract (Steps 3–4): flip the arrows, agree on update.
  3. Clean star (Step 5): one loop, fixed-size Subject, O(1) to add listeners.
  4. Guards (Steps 6–8): empty list is free, copy-on-iterate for safe unsubscribe, try/except for dead observers.

This is exactly the mechanism behind Publish-Subscribe & Event-Driven Architecture at large scale, and it is the Open/Closed Principle in action: the Subject is closed for modification yet open for extension through subscription. The blind o.update(data) call is Polymorphism doing the heavy lifting — same call, many behaviours.

Recall Feynman retelling — say it back in plain words

Picture a town crier. In the naive version the crier keeps a little black book with every citizen's exact address and personally knocks on each door — add a citizen, the crier rewrites the book. That is high coupling and it does not scale.

The Observer fix: the crier keeps just an open sign-up sheet. Anyone who wants news writes their name (subscribe). When news breaks the crier walks the sheet top to bottom and shouts the same message at each name (notifyupdate). The crier never knows who the citizens are, only that each one has an ear that answers to "here is the news."

Empty sheet? The crier shouts to no one and goes home — no fuss. Someone crosses their name off mid-shout? The crier reads from a photocopy of the sheet so the walk doesn't stumble. A citizen faints when addressed? The crier steps over them and continues down the sheet. That photocopy, that step-over, and that sign-up sheet are the whole pattern.

Recall Self-test

Why does looping over a list make "zero observers" require no special code? ::: The for loop over an empty list simply runs zero times and returns — the boundary is the natural behaviour, not a branch. What single line makes it safe for an observer to unsubscribe during notify? ::: Iterate over a copy: for o in list(self._observers):. Which SOLID principle does the fixed-size Subject demonstrate? ::: Open/Closed — closed for modification, open for extension via subscription.