2.2.12 · D4Design Principles

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

2,335 words11 min readBack to topic

Before we start, one map of the whole page so you always know which rung you are on.


Level 1 — Recognition

Goal: given a symptom, name the single pattern that cures it. No code yet — just matching a problem to a tool.

Exercise 1.1

A weather station's Temperature object must update a phone display, a web widget, and a CSV logger whenever it reads a new value — and users can add or remove displays while the program runs. Which behavioral pattern fits, and what are the two roles involved?

Recall Solution

Pattern: Observer.

  • The Temperature object is the Subject: it keeps a list of listeners and calls notify() on change.
  • Each display/logger is an Observer: it implements a common update(data) method. Why not the others? The key phrase is "one thing changes → many things react automatically, added/removed at runtime". That fan-out is Observer's exact signature. See Publish-Subscribe & Event-Driven Architecture for the same idea at network scale.

Exercise 1.2

A checkout page can charge via credit card, PayPal, or crypto. The rest of the code should not contain if method == "paypal": .... Which pattern, and what does the client choose?

Recall Solution

Pattern: Strategy.

  • The checkout is the Context; each payment algorithm is a Strategy implementing one pay(amount) interface.
  • The client picks which strategy object to hand in; the context never branches on the method name.

Exercise 1.3

A text editor needs Ctrl-Z. Each user action must be storable so it can be reversed later. Which pattern turns "actions" into storable things?

Recall Solution

Pattern: Command. Each action becomes an object with execute() and undo(). A history list stacks them, so undo() pops and reverses. This is exactly Undo-Redo Systems.


Level 2 — Application

Goal: write small, correct code. You produce the classes, not just the name.

Exercise 2.1

Write a minimal Observer in Python: a Bell subject that lets observers subscribe, and notifies them with a string. Show that a Student observer prints Ding for <name>. Add one student, ring, and give the printed output.

Recall Solution
class Bell:
    def __init__(self):
        self._observers = []            # subject owns the list
    def subscribe(self, o):  self._observers.append(o)
    def ring(self, msg):
        for o in self._observers:       # decoupled fan-out
            o.update(msg)
 
class Student:
    def __init__(self, name): self.name = name
    def update(self, msg):    print(f"Ding for {self.name}: {msg}")
 
b = Bell(); b.subscribe(Student("Ana"))
b.ring("lunch")

Output: Ding for Ana: lunch Note ring returns nothing — Observer is push, the subject does not collect answers.

Exercise 2.2

Using Strategy, build a Calculator context whose behavior is a function passed in. Call it with an "add" strategy lambda a, b: a + b on inputs (4, 6), then swap to a "multiply" strategy lambda a, b: a * b on the same inputs. Give both results.

Recall Solution
class Calculator:
    def __init__(self, strategy): self.strategy = strategy
    def run(self, a, b): return self.strategy(a, b)   # delegate, no if/else
 
print(Calculator(lambda a, b: a + b).run(4, 6))   # 10
print(Calculator(lambda a, b: a * b).run(4, 6))   # 24

Results: 10 then 24. In Python a plain function is a strategy — no class hierarchy needed.

Exercise 2.3

Using Command, implement AddNumber on a list doc, with execute() appending and undo() popping. Run three commands adding 10, 20, 30, then undo once. What is doc, and what is its sum?

Recall Solution
class AddNumber:
    def __init__(self, doc, n): self.doc, self.n = doc, n
    def execute(self): self.doc.append(self.n)
    def undo(self):    self.doc.pop()
 
doc, history = [], []
def run(cmd): cmd.execute(); history.append(cmd)
def undo():   history.pop().undo()
 
for n in (10, 20, 30): run(AddNumber(doc, n))
undo()

After the three runs doc == [10, 20, 30]; one undo() pops the last, giving doc == [10, 20], sum 30.


Level 3 — Analysis

Goal: diagnose and compare. Given two patterns or a smell, explain the difference precisely.

Exercise 3.1

Strategy and State share the same UML: a Context holding an interface reference. State three concrete differences that let you tell them apart in real code.

Recall Solution
  1. Who chooses the object. Strategy: the client injects it. State: the object often creates its successor.
  2. Knowledge of siblings. Strategy objects are independent and ignorant of each other. State objects know the next state and trigger the switch.
  3. Frequency of change. A Strategy is usually set once and stays. A State changes repeatedly during the context's life (Draft → Moderation → Published). Structure is identical; intent distinguishes them (see Polymorphism — both lean on it, differently).

Exercise 3.2

A junior wrote this "Strategy":

class Sorter:
    def sort(self, data, mode):
        if mode == "asc":  return sorted(data)
        elif mode == "desc": return sorted(data, reverse=True)

Explain why this is not Strategy, and rewrite it as true Strategy. Which SOLID principle does the original violate?

Recall Solution

It is not Strategy — the algorithms are hard-coded inside an if/elif, so adding a "shuffle" mode means editing Sorter. That violates the Open/Closed Principle (open for extension, closed for modification). True Strategy:

class Sorter:
    def __init__(self, strategy): self.strategy = strategy
    def sort(self, data): return self.strategy(data)
 
asc  = Sorter(sorted)
desc = Sorter(lambda d: sorted(d, reverse=True))
print(asc.sort([3, 1, 2]))    # [1, 2, 3]
print(desc.sort([3, 1, 2]))   # [3, 2, 1]

Now a new algorithm is a new object, no edit to Sorter.

Exercise 3.3

In Chain of Responsibility, a request passes through handlers L1 → L2 → Manager. A ticket of severity 5 needs a handler whose max_level >= 5. Given L1.max=2, L2.max=4, Manager.max=10, trace which handler resolves it and why the earlier two pass it on.

Recall Solution
  • L1 (max=2): 5 > 2 → cannot handle → passes to next.
  • L2 (max=4): 5 > 4 → cannot handle → passes to next.
  • Manager (max=10): 5 <= 10handles it. The chain stops at the first handler able to cope. Each handler only knows its successor, never the whole chain — that is the decoupling Chain of Responsibility buys.

Level 4 — Synthesis

Goal: combine two or more patterns into one working design.

Exercise 4.1

Design a smart thermostat that (a) lets the current mode — Eco, Comfort, Away — decide the target temperature, and (b) notifies a display and a logger whenever the target changes. Which two patterns combine, and which role plays which? Sketch the classes.

Recall Solution

Combine State (the mode decides behavior and the next mode) with Observer (target-change fan-out).

  • Thermostat is the Context and the Subject.
  • Eco/Comfort/Away are State objects returning a target temperature.
  • Display/Logger are Observers.
class Eco:     target = 18
class Comfort: target = 23
 
class Thermostat:
    def __init__(self):
        self.state = Eco(); self._obs = []
    def subscribe(self, o): self._obs.append(o)
    def set_mode(self, state):
        self.state = state
        for o in self._obs:            # Observer fan-out
            o.update(self.state.target)

Setting the mode changes State; the resulting target-change triggers Observer notification. Each pattern owns one axis of change.

Exercise 4.2

Extend the editor from 2.3 with Command + Iterator: after running commands adding 10, 20, 30, iterate the history to replay every command onto a fresh empty list. What does the replayed list contain?

Recall Solution
fresh = []
for cmd in history:            # Iterator: for-loop over the command stack
    AddNumber(fresh, cmd.n).execute()

Iterating history (Iterator) and calling each stored execute() (Command) rebuilds the sequence: fresh == [10, 20, 30], sum 60. Because commands are first-class objects, they can be stored, iterated, and replayed — that is what makes macros and redo possible.


Level 5 — Mastery

Goal: design and defend under realistic constraints.

Exercise 5.1

A document workflow is Draft → Moderation → Published, and only from Published can you go to Archived. Implement it with State so that calling publish() on an already-Published doc is a safe no-op, and calling archive() on a Draft is rejected. Trace the state after: d.publish(); d.publish(); d.archive().

Recall Solution
class Draft:
    def publish(self, ctx): ctx.state = Moderation()
    def archive(self, ctx): print("cannot archive a draft")   # rejected
class Moderation:
    def publish(self, ctx): ctx.state = Published()
    def archive(self, ctx): print("cannot archive under moderation")
class Published:
    def publish(self, ctx): print("already live")             # safe no-op
    def archive(self, ctx): ctx.state = Archived()
class Archived:
    def publish(self, ctx): print("archived is final")
    def archive(self, ctx): print("already archived")
 
class Doc:
    def __init__(self): self.state = Draft()
    def publish(self): self.state.publish(self)
    def archive(self): self.state.archive(self)

Trace from Draft:

  1. d.publish()Draft.publish → state becomes Moderation.
  2. d.publish()Moderation.publish → state becomes Published.
  3. d.archive()Published.archive → state becomes Archived. Final state: Archived. Every illegal transition is handled by the state that owns it, so no scattered if checks exist — adding a new status is adding a class.

Exercise 5.2

You must add "count the words" and "spell-check" operations to a class hierarchy of document nodes (Paragraph, Image, Table) — without editing those node classes, because they ship in a locked library. Which pattern, why, and what is the trade-off you accept?

Recall Solution

Pattern: Visitor.

  • Each node accepts a Visitor via accept(visitor), which calls back visitor.visitParagraph(self) etc. New operations = new Visitor classes; the node classes stay untouched (respects Open/Closed for operations).
  • Trade-off: Visitor makes adding new operations cheap but adding a new node type expensive — every existing visitor must gain a visitNewNode method. So Visitor is right precisely when the node hierarchy is stable and operations grow. If it were the reverse (nodes change often), Visitor would be the wrong tool.

Recall Self-test recap

Symptom "one change, many reactors, runtime add/remove" ::: Observer Symptom "one task, interchangeable algorithms chosen by client" ::: Strategy Symptom "actions must be stored, queued, undone" ::: Command Symptom "behavior changes as internal status changes, states know successors" ::: State Symptom "request tried by handlers until one copes" ::: Chain of Responsibility Symptom "add operations to a fixed, locked class hierarchy" ::: Visitor Structure of Strategy and State is identical; what distinguishes them ::: intent (who chooses / do objects know their successor)