PYTH-270 · Lesson 6 of 12 · 26 min

Behavioral patterns

▶ Video coming soon A video version of this lesson is on the way.

Behavioral patterns are about how objects divide up work and communicate: who decides, who is told when something happens, and how a request finds the code that should handle it. They are the patterns that most often replace long if-chains and tangled flags with small, separate pieces. In Ridgeline's order code, the discount logic was a nest of conditions, the low-stock alert was a call buried in the middle of the sell function, the order status was a string that any code could set to anything, and the stock tool's undo feature was a special case in every command.

This lesson covers the behavioral patterns that matter most in everyday Python: strategy, observer, command, state, template method and chain of responsibility, with a note on iterator, which Python builds into the language. For each, you will see the problem in Ridgeline's code, the classic idea and the Pythonic implementation.

Strategy: interchangeable algorithms

The strategy pattern defines a family of algorithms, puts each one in its own object, and makes them interchangeable so the caller can choose one at run time. Ridgeline prices orders differently for cafe customers, online customers, wholesale accounts and subscribers. The original code decided with an if-chain inside the pricing function; every new customer type meant editing that function, which breaks the open-closed principle from lesson 2.

In the Gang of Four's book, each strategy is a class implementing a common interface. In Python, where functions are objects, a strategy is usually just a function, and the family is a dictionary of functions. The caller looks up the right strategy and calls it. Adding a strategy means adding a function and a dictionary entry; the pricing code itself does not change.

Strategies as plain functions chosen from a dictionary
def cafe(subtotal):
    return subtotal

def online(subtotal):
    return subtotal + (8.95 if subtotal < 50 else 0)

def wholesale(subtotal):
    return subtotal * 0.85

def subscriber(subtotal):
    return subtotal * 0.9

PRICING = {"cafe": cafe, "online": online, "wholesale": wholesale, "subscriber": subscriber}

def price_order(customer_type, subtotal):
    strategy = PRICING[customer_type]
    return round(strategy(subtotal), 2)

for kind in PRICING:
    print(f"{kind:10} {price_order(kind, 40.00):7.2f}")
Output
cafe         40.00
online       48.95
wholesale    34.00
subscriber   36.00

When a strategy needs configuration or state, such as a wholesale rate that differs by account, use a class with a __call__ method, a closure, or functools.partial; all three produce something that can be called like the plain functions, so the caller does not care which it receives.

Try it yourself

Shipping strategies

Write three strategy functions, each taking a weight in kilograms and returning a cost: pickup (always 0.0), standard (5.0 plus 1.5 per kg) and express (12.0 plus 2.0 per kg). Put them in a dictionary SHIPPING under the keys "pickup", "standard" and "express".

Then write ship_cost(method, weight_kg) that looks up the strategy, calls it and rounds to 2 decimal places. Unknown methods raise KeyError, which happens naturally with a dictionary lookup.

def ship_cost(method, weight_kg):
    if method == "pickup":
        return 0.0
    elif method == "standard":
        return round(5.0 + 1.5 * weight_kg, 2)
    return round(12.0 + 2.0 * weight_kg, 2)

Observer: telling interested parties what happened

The observer pattern lets an object, the subject, notify any number of other objects, the observers, when something happens, without knowing who they are. Ridgeline's Inventory.sell method once contained a call to send_low_stock_email, then a call to update the web shop's sold-out badge, then a call to log the sale for Anita's report. Every new reaction to a sale meant editing sell, and the inventory module depended on email, the web shop and reporting.

With an observer, or its close cousin the publish-subscribe event bus, the inventory simply announces stock_changed with the details, and each interested part of the program subscribes a handler. The inventory knows nothing about emails or badges; adding a reaction means subscribing a new handler. This is one of the most effective ways to cut coupling between modules.

An event bus: the publisher knows nothing about who reacts
from collections import defaultdict

class EventBus:
    def __init__(self):
        self._handlers = defaultdict(list)

    def subscribe(self, event, handler):
        self._handlers[event].append(handler)

    def publish(self, event, **data):
        for handler in self._handlers[event]:
            handler(**data)

bus = EventBus()
outbox = []

def low_stock_alert(sku, stock):
    if stock < 10:
        outbox.append(f"Reorder {sku}: only {stock} bags left")

def sold_out_badge(sku, stock):
    if stock == 0:
        outbox.append(f"Show SOLD OUT on {sku}")

bus.subscribe("stock_changed", low_stock_alert)
bus.subscribe("stock_changed", sold_out_badge)

bus.publish("stock_changed", sku="RC-104", stock=8)
bus.publish("stock_changed", sku="RC-105", stock=0)
print("\n".join(outbox))
Output
Reorder RC-104: only 8 bags left
Reorder RC-105: only 0 bags left
Show SOLD OUT on RC-105
The Inventory box on the left publishes a stock_changed event to an event bus in the middle. Three subscribers on the right receive it: low-stock alert, sold-out badge and sales log. A note says the inventory does not know who is listening.
The inventory publishes an event; any number of subscribers react

Observers come with costs to keep in mind. The flow of control is harder to follow, because reading sell no longer tells you everything that happens after a sale. Handlers run in order inside publish, so a slow or failing handler affects the publisher; robust buses catch and log handler errors. And a handler that subscribes but is never unsubscribed keeps its objects alive. Use events for genuine reactions to something that happened, not to hide ordinary function calls.

Try it yourself

An event bus with unsubscribe

Write an EventBus class with subscribe(event, handler), unsubscribe(event, handler) and publish(event, data). publish calls every handler subscribed to that event, in the order they subscribed, passing data, and returns how many handlers it called.

Publishing an event nobody subscribed to returns 0. Unsubscribing removes that handler so it is no longer called.

class EventBus:
    def __init__(self):
        self.handlers = []

    def subscribe(self, event, handler):
        self.handlers.append(handler)

    def publish(self, event, data):
        for h in self.handlers:
            h(data)

Command: requests as objects

The command pattern turns a request into an object that holds everything needed to carry it out later. Once requests are objects, you can queue them, log them, retry them, and, most usefully, undo them. The stock tool from PYTH-101 had sell and restock commands typed at a prompt. Adding undo meant writing a reverse operation for every command and remembering what was done. As command objects, each with execute and undo methods, the history becomes a simple stack, the structure you studied in PYTH-201.

Commands with execute and undo; the history is a stack
class Sell:
    def __init__(self, stock, sku, bags):
        self.stock, self.sku, self.bags = stock, sku, bags

    def execute(self):
        if self.stock[self.sku] < self.bags:
            raise ValueError("not enough stock")
        self.stock[self.sku] -= self.bags

    def undo(self):
        self.stock[self.sku] += self.bags

class Restock(Sell):
    def execute(self):
        self.stock[self.sku] += self.bags

    def undo(self):
        self.stock[self.sku] -= self.bags

class History:
    def __init__(self):
        self._done = []

    def run(self, command):
        command.execute()
        self._done.append(command)

    def undo(self):
        if self._done:
            self._done.pop().undo()

stock = {"RC-101": 42, "RC-104": 9}
history = History()
history.run(Sell(stock, "RC-101", 3))
history.run(Restock(stock, "RC-104", 30))
print(stock)
history.undo()
history.undo()
print(stock)
Output
{'RC-101': 39, 'RC-104': 39}
{'RC-101': 42, 'RC-104': 9}

The design pays off beyond undo. Because a Sell is an object, the shop can save a list of commands to replay a day's sales against a backup, or send commands to a queue when the database is briefly unavailable. Many user interfaces, from text editors to spreadsheets, are built on exactly this structure.

State: behavior that depends on status

The state pattern lets an object change its behavior when its internal state changes, as if it changed its class. Orders at Ridgeline move through a lifecycle: new, paid, packed, shipped, delivered, with canceled possible from new or paid. In the old code, status was a string anyone could set, and checks like if order.status in ('new', 'paid') were scattered across the shop, the packing screen and the reports. Orders were sometimes marked shipped without ever being paid.

The full Gang of Four version gives each state its own class with methods for each action. For many Python programs, a simpler form is enough: an explicit table of allowed transitions, and one method that changes the state only along a permitted arrow. Either way, the rules live in one place, and illegal moves fail loudly.

A state diagram with circles for new, paid, packed, shipped and delivered connected left to right by arrows labeled pay, pack, ship and deliver. A canceled circle below receives arrows labeled cancel from new and from paid.
The order lifecycle as a state machine: only the drawn arrows are allowed

Try it yourself

The order state machine

Write a class Order whose status starts as "new". The allowed transitions are: new to paid (action "pay"), paid to packed ("pack"), packed to shipped ("ship"), shipped to delivered ("deliver"), and "cancel" from new or paid to canceled.

Write apply(action) that moves to the next state when the action is allowed from the current state and raises ValueError otherwise, leaving the status unchanged. Keep the rules in a dictionary TRANSITIONS mapping (state, action) pairs to the new state.

class Order:
    def __init__(self):
        self.status = "new"

    def apply(self, action):
        if action == "pay":
            self.status = "paid"
        elif action == "ship":
            self.status = "shipped"
        elif action == "cancel":
            self.status = "cancelled"

Template method and chain of responsibility

The template method pattern puts the fixed outline of an algorithm in a base class method and lets subclasses fill in particular steps. Ridgeline's reports all follow the same outline: load records, filter them to the period, compute figures, format the output. A Report base class can define run() as those four calls, with subclasses providing compute and format. In Python, passing the variable steps as functions to a single run_report function is often simpler, but the template method is common in frameworks: a unittest.TestCase subclass fills in setUp and test methods inside an outline the framework controls.

The chain of responsibility passes a request along a chain of handlers until one deals with it, or lets each handler contribute. Ridgeline's discount rules form a chain: a staff discount, then a loyalty-tier discount, then a promotion code, each looking at the order and either adjusting it or passing it on. Adding a rule means adding a link, and the order of the links is explicit in one list. Python's logging module uses a chain of handlers in the same way, and web frameworks pass each request through a chain of middleware.

A chain of discount handlers, each deciding whether to act
def staff_discount(order):
    if order["staff"]:
        order["total"] *= 0.7
        order["applied"].append("staff 30 off")

def loyalty_discount(order):
    if order["tier"] == "Reserve":
        order["total"] *= 0.95
        order["applied"].append("Reserve 5 off")

def promo_code(order):
    if order.get("code") == "WELCOME10" and not order["staff"]:
        order["total"] *= 0.9
        order["applied"].append("WELCOME10")

CHAIN = [staff_discount, loyalty_discount, promo_code]     # the order is explicit

def apply_discounts(order):
    for handler in CHAIN:
        handler(order)
    order["total"] = round(order["total"], 2)
    return order

print(apply_discounts({"total": 100.0, "staff": False, "tier": "Reserve", "code": "WELCOME10", "applied": []}))
print(apply_discounts({"total": 100.0, "staff": True, "tier": "Bean", "code": "WELCOME10", "applied": []}))
Output
{'total': 85.5, 'staff': False, 'tier': 'Reserve', 'code': 'WELCOME10', 'applied': ['Reserve 5 off', 'WELCOME10']}
{'total': 70.0, 'staff': True, 'tier': 'Bean', 'code': 'WELCOME10', 'applied': ['staff 30 off']}

Each handler is small and independently testable, and the list CHAIN documents the order in which discounts stack, which matters to Anita because 30 off followed by 10 off is not the same as 40 off. A rule such as staff cannot combine their discount with promotion codes is now visible in one handler instead of hidden in a tangle of conditions.

Iterator: built into the language

The iterator pattern, which gives sequential access to the elements of a collection without exposing how it is stored, was a significant design idea in 1994. In Python it is part of the language: any object with __iter__ works in a for loop, and generators, which you met in PYTH-102, make writing iterators almost effortless. When you give Inventory an __iter__ method that yields coffees in SKU order, callers can loop over it without knowing whether coffees are stored in a dictionary, a list or a database. That is the whole pattern.

Worked problem

Which behavioral pattern?

Rosa's wish list for the next release contains four items. Lena asks Sam to name the pattern each one suggests before any code is written.

Show solution
  1. Let customers choose between whole bean, filter grind and espresso grind, each with a different preparation fee: interchangeable algorithms chosen at run time, so strategy.
  2. When a subscription renews, email the customer, update Anita's revenue forecast and reserve stock: several independent reactions to one event, so observer.
  3. Let staff undo the last adjustment on the inventory screen: requests stored as objects with undo, so command.
  4. Prevent subscriptions from being shipped while paused, and from being paused when canceled: behavior that depends on status with explicit allowed moves, so state.

Answer: Strategy for grind fees, observer for renewal reactions, command for undo and state for the subscription lifecycle.

Common mistakes

  • MistakeGrowing an if-chain every time a new variant of an algorithm appears.

    InsteadUse strategies: a dictionary of functions or callables chosen at run time.

  • MistakeCalling every interested module directly from the code where something happens.

    InsteadPublish an event and let interested modules subscribe.

  • MistakeUsing events for ordinary calls, so the program's flow becomes impossible to follow.

    InsteadReserve observers for genuine reactions; call functions directly when one result is needed.

  • MistakeStoring status as a free-form string that any code can set.

    InsteadKeep allowed transitions in one table or state classes and change state through one method.

  • MistakeWriting a separate reverse operation for each command when adding undo.

    InsteadMake each command an object with execute and undo, and keep a history stack.

  • MistakeWriting iterator classes by hand.

    InsteadUse a generator in __iter__.

Summary and key terms

  • Behavioral patterns divide work among objects and decide how they communicate.
  • Strategy: interchangeable algorithms, usually plain functions in a dictionary in Python.
  • Observer and publish-subscribe: the subject announces events and subscribers react, cutting coupling.
  • Command: requests become objects that can be queued, logged, retried and undone.
  • State: behavior depends on status, and allowed transitions are kept in one place.
  • Template method fixes an algorithm's outline and lets subclasses fill in steps; chain of responsibility passes requests along handlers.
  • Iterator is built into Python through __iter__ and generators.
behavioral pattern
A pattern about how objects divide work and communicate.
strategy
One of a family of interchangeable algorithms selected at run time.
observer
An object notified by a subject when something happens.
publish-subscribe
Communication in which publishers send events to a bus and subscribers receive them.
command
An object representing a request, often with execute and undo.
state machine
A model with defined states and allowed transitions between them.
state pattern
Letting an object's behavior change with its internal state.
template method
A base-class method that fixes an algorithm's outline with steps filled in by subclasses.
chain of responsibility
Passing a request along a sequence of handlers.
iterator
An object that gives sequential access to a collection's elements.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. In Python, what is usually the simplest form of a strategy?

    Show answer

    A function, often stored in a dictionary under a name.

  2. Why does an event bus reduce coupling?

    Show answer

    The publisher does not know which modules react, so new reactions can be added without changing it.

  3. What data structure holds a command history for undo?

    Show answer

    A stack: the most recent command is undone first.

  4. What goes wrong when order status is a free-form string?

    Show answer

    Any code can set any value, so illegal states such as shipped-but-unpaid appear.

  5. Which standard-library class uses the template method pattern?

    Show answer

    unittest.TestCase: the framework runs setUp, the test and tearDown in a fixed outline.

  6. How does Python support the iterator pattern directly?

    Show answer

    Through __iter__, the iteration protocol and generators.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz