Behavioral Patterns
Patterns for how objects work together: strategy, observer, command, state, template method and iterator, and how they show up in everyday frameworks.
The third group of GoF patterns is about behavior: which object does what, and how objects hand work to each other. Of the eleven behavioral patterns in the book, the six on this page are the ones you meet every day, often without noticing, because modern languages and frameworks have built them in. Many of them become simpler with higher-order functions, functions that take or return other functions: where the 1994 book needs an interface and a class, Python often needs one function. The examples follow Kestrel Energy's billing system, KBS, and its meter-api.
Strategy: swappable algorithms
KBS calculates a day's cost differently for each contract type: one price for a fixed contract, a day and a night price for a day-night contract, the hourly market price for a dynamic contract. The strategy pattern puts each variant of an algorithm behind the same interface, so the code that uses it can pick one at run time and the variants can change independently of that code. In Python, the shared interface can simply be a function signature:
from decimal import Decimal
# Hourly market prices for one day: cheap at night, expensive at the evening peak.
MARKET = [Decimal("0.18")] * 7 + [Decimal("0.27")] * 10 + [Decimal("0.41")] * 4 + [Decimal("0.24")] * 3
# Three strategies with the same signature: 24 hourly kWh values -> cost.
def fixed(hourly_kwh):
return sum(hourly_kwh) * Decimal("0.2700")
def day_night(hourly_kwh):
night = sum(kwh for hour, kwh in enumerate(hourly_kwh) if hour < 7 or hour >= 23)
return night * Decimal("0.2240") + (sum(hourly_kwh) - night) * Decimal("0.3120")
def dynamic(hourly_kwh):
return sum(kwh * price for kwh, price in zip(hourly_kwh, MARKET))
STRATEGIES = {"fixed": fixed, "day-night": day_night, "dynamic": dynamic}
def daily_cost(contract_type, hourly_kwh):
calculate = STRATEGIES[contract_type] # chosen at run time
return round(calculate(hourly_kwh), 2)
# One household's day, with an evening peak from 17:00 to 21:00.
usage = [Decimal("0.3")] * 7 + [Decimal("0.5")] * 10 + [Decimal("1.2")] * 4 + [Decimal("0.6")] * 3
print(f"usage: {sum(usage)} kWh")
for contract_type in STRATEGIES:
print(f"{contract_type:10} EUR {daily_cost(contract_type, usage)}")
usage: 13.7 kWh fixed EUR 3.70 day-night EUR 4.04 dynamic EUR 4.13
daily_cost contains no if per contract
type, and a new contract type is one new function plus one
dictionary entry. You use strategies all the time: the
key argument of sorted() and the
Comparator passed to a Java sort are strategies for
"how to compare". In Java and C#, a strategy is an object that
implements an interface, often written as a lambda for a
functional interface. The
factory on the
previous page and a strategy often work together: the factory picks
the strategy from the contract record.
Observer: notifying whoever is interested
When meter-api stores a new reading, several other things should happen: the cached usage graph must be refreshed, and a customer with very high usage gets a warning by email. Next year there will be a third reaction. If the store calls each of them by name, every new reaction means changing the store.
The observer pattern lets an object (the subject) keep a list of interested objects or functions (the observers) and notify all of them when something changes, without knowing what they do. Observers register and unregister themselves:
class ReadingStore:
"""Subject: keeps a list of observers and notifies them of new readings."""
def __init__(self):
self._observers = []
def subscribe(self, observer):
self._observers.append(observer)
def unsubscribe(self, observer):
self._observers.remove(observer)
def save(self, ean, kwh):
print(f"stored {ean}: {kwh} kWh")
for notify in self._observers:
notify(ean, kwh)
# Observers: plain functions with the same signature.
def refresh_usage_graph(ean, kwh):
print(f" graph cache for {ean} cleared")
def check_high_usage(ean, kwh):
if kwh > 2.0:
print(f" high-usage email queued for {ean}")
store = ReadingStore()
store.subscribe(refresh_usage_graph)
store.subscribe(check_high_usage)
store.save("E-0412", 0.4)
store.save("E-0413", 3.1)
store.unsubscribe(refresh_usage_graph)
store.save("E-0412", 0.5)
stored E-0412: 0.4 kWh graph cache for E-0412 cleared stored E-0413: 3.1 kWh graph cache for E-0413 cleared high-usage email queued for E-0413 stored E-0412: 0.5 kWh
Each observer here is a
callback function. The same pattern is everywhere in user interfaces:
addEventListener in the browser registers an
event listener, which is an observer of a button or a page.
In TypeScript, generics can make the observer type-safe: each event
name is tied to the shape of its payload, so a listener for
readingStored knows it receives a reading, and
on() returns a function that unsubscribes again:
interface Reading {
ean: string;
kwh: number;
}
type Listener<T> = (event: T) => void;
// A small, typed subject: listeners for each event name get the right payload.
class Events<Map extends Record<string, unknown>> {
private listeners: { [K in keyof Map]?: Listener<Map[K]>[] } = {};
on<K extends keyof Map>(name: K, listener: Listener<Map[K]>): () => void {
const list = (this.listeners[name] ??= []);
list.push(listener);
return () => {
const i = list.indexOf(listener);
if (i >= 0) list.splice(i, 1); // unsubscribe (safe to call twice)
};
}
emit<K extends keyof Map>(name: K, event: Map[K]): void {
for (const listener of this.listeners[name] ?? []) listener(event);
}
}
const events = new Events<{ readingStored: Reading; meterReplaced: { ean: string } }>();
const stop = events.on("readingStored", (r) => console.log(`graph cache for ${r.ean} cleared`));
events.on("readingStored", (r) => {
if (r.kwh > 2.0) console.log(`high-usage email queued for ${r.ean}`);
});
events.on("meterReplaced", (e) => console.log(`new meter at ${e.ean}`));
events.emit("readingStored", { ean: "E-0413", kwh: 3.1 });
stop();
events.emit("readingStored", { ean: "E-0412", kwh: 0.5 });
events.emit("meterReplaced", { ean: "E-0412" });
graph cache for E-0413 cleared high-usage email queued for E-0413 new meter at E-0412
The payoff of the types is that mistakes in what is sent to
observers are caught before anything runs. A typo in a payload is a
compile error, not a listener that quietly reads
undefined:
interface Reading {
ean: string;
kwh: number;
}
type Listener<T> = (event: T) => void;
const listeners: Listener<Reading>[] = [];
function emit(reading: Reading): void {
for (const listener of listeners) listener(reading);
}
listeners.push((r) => console.log(r.ean.toUpperCase()));
emit({ ean: "E-0412", kwH: 0.5 });
example.ts(15,23): error TS2561: Object literal may only specify known properties, but 'kwH' does not exist in type 'Reading'. Did you mean to write 'kwh'?
Command: requests as objects
Lucía Romero, a billing clerk, corrects invoices all day: credit an amount for a wrong meter reading, a goodwill gesture after an outage. Each correction must be logged for audit, and a mistake in the last correction should be undoable without a database administrator.
The command pattern turns a
request into an object that holds everything needed to carry it out
later. Because it is an object, it can be stored in a queue or a
log, run by code that does not know what it does, and given an
undo() method:
from dataclasses import dataclass
from decimal import Decimal
class Account:
def __init__(self, customer_id, balance):
self.customer_id = customer_id
self.balance = Decimal(balance)
@dataclass
class CreditInvoice:
"""Command: a request turned into an object, with an undo."""
account: Account
amount: Decimal
reason: str
def execute(self):
self.account.balance -= self.amount
def undo(self):
self.account.balance += self.amount
def describe(self):
return f"credit {self.amount} to {self.account.customer_id} ({self.reason})"
class CorrectionLog:
"""Invoker: runs commands, keeps an audit trail and an undo stack."""
def __init__(self):
self.done = []
def run(self, command, clerk):
command.execute()
self.done.append(command)
print(f"{clerk}: {command.describe()}")
def undo_last(self, clerk):
command = self.done.pop()
command.undo()
print(f"{clerk}: UNDO {command.describe()}")
account = Account("C-10442", "118.65")
log = CorrectionLog()
log.run(CreditInvoice(account, Decimal("12.40"), "wrong meter reading"), "lucia")
log.run(CreditInvoice(account, Decimal("150.00"), "goodwill"), "lucia") # meant 15.00
log.undo_last("lucia")
log.run(CreditInvoice(account, Decimal("15.00"), "goodwill"), "lucia")
print("balance:", account.balance)
lucia: credit 12.40 to C-10442 (wrong meter reading) lucia: credit 150.00 to C-10442 (goodwill) lucia: UNDO credit 150.00 to C-10442 (goodwill) lucia: credit 15.00 to C-10442 (goodwill) balance: 91.25
CorrectionLog runs, records and undoes commands without
knowing what a credit is; a CorrectReading or
CancelInvoice command plugs into the same log. Undo
stacks in editors, job queues that store "work to do" for a worker,
and database migrations with an "up" and a "down" step are all
commands. When the command only needs to be run, never undone or
stored, a function or a
lambda is the simpler
Python version.
State: behavior that depends on the current state
A contract is pending, active, moving or ended, and the same request
means something different in each: "move" on an active contract
starts a final meter reading, on a pending contract it is an error.
Written with if statements, every method of
Contract repeats the same check of the current state,
and the rules of one state are spread over all of them.
The state pattern gives each state its own class with the same methods; the object delegates every request to its current state object, which does the work and decides the next state. To the caller, the object seems to change its class when its state changes:
class Contract:
"""Context: delegates to its current state object."""
def __init__(self, number):
self.number = number
self.state = Pending()
def activate(self):
self.state = self.state.activate(self)
def request_move(self):
self.state = self.state.request_move(self)
def end(self):
self.state = self.state.end(self)
class State:
"""Default: the action is not allowed in this state."""
def refuse(self, contract, action):
print(f"{contract.number}: cannot {action} while {self.name}")
return self
def activate(self, contract):
return self.refuse(contract, "activate")
def request_move(self, contract):
return self.refuse(contract, "move")
def end(self, contract):
return self.refuse(contract, "end")
class Pending(State):
name = "pending"
def activate(self, contract):
print(f"{contract.number}: supply starts, first advance payment scheduled")
return Active()
class Active(State):
name = "active"
def request_move(self, contract):
print(f"{contract.number}: final meter reading requested for the old address")
return Moving()
def end(self, contract):
print(f"{contract.number}: final invoice scheduled")
return Ended()
class Moving(State):
name = "moving"
def activate(self, contract):
print(f"{contract.number}: supply continues at the new address")
return Active()
class Ended(State):
name = "ended"
contract = Contract("CT-20417")
contract.request_move() # not allowed yet
contract.activate()
contract.request_move()
contract.end() # not allowed during a move
contract.activate()
contract.end()
print("final state:", contract.state.name)
CT-20417: cannot move while pending CT-20417: supply starts, first advance payment scheduled CT-20417: final meter reading requested for the old address CT-20417: cannot end while moving CT-20417: supply continues at the new address CT-20417: final invoice scheduled final state: ended
Everything an active contract may do is in Active; a
new state such as "suspended" is a new class, and the base class
makes "not allowed" the safe default. Network protocols are state
machines in the same sense: a TCP connection behaves differently in
each of its states.
Modern Java: sealed types and switch
Modern Java offers a different way to model the same thing. A
sealed interface
lists every possible state, each state is a record that can carry
its own data (the due date of a sent invoice, the number of
reminders of an overdue one), and a switch with pattern
matching holds all the transitions in one function. This invoice
lifecycle uses a compact source file:
import java.time.LocalDate;
enum Event { SEND, PAY, DUE_DATE_PASSED }
// Every possible state, and nothing else: the compiler knows the full list.
sealed interface InvoiceState permits Draft, Sent, Overdue, Paid {}
record Draft() implements InvoiceState {}
record Sent(LocalDate due) implements InvoiceState {}
record Overdue(int reminders) implements InvoiceState {}
record Paid() implements InvoiceState {}
InvoiceState next(InvoiceState state, Event event) {
return switch (state) {
case Draft _ -> event == Event.SEND ? new Sent(LocalDate.of(2026, 11, 7)) : refuse(state, event);
case Sent _ -> switch (event) {
case PAY -> new Paid();
case DUE_DATE_PASSED -> new Overdue(1);
default -> refuse(state, event);
};
case Overdue o -> switch (event) {
case PAY -> new Paid();
case DUE_DATE_PASSED -> new Overdue(o.reminders() + 1);
default -> refuse(state, event);
};
case Paid _ -> refuse(state, event);
};
}
InvoiceState refuse(InvoiceState state, Event event) {
IO.println(" refused: " + event + " in " + state);
return state;
}
void main() {
InvoiceState state = new Draft();
Event[] events = {Event.PAY, Event.SEND, Event.DUE_DATE_PASSED,
Event.DUE_DATE_PASSED, Event.PAY, Event.SEND};
for (Event event : events) {
state = next(state, event);
IO.println(event + " -> " + state);
}
}
refused: PAY in Draft[] PAY -> Draft[] SEND -> Sent[due=2026-11-07] DUE_DATE_PASSED -> Overdue[reminders=1] DUE_DATE_PASSED -> Overdue[reminders=2] PAY -> Paid[] refused: SEND in Paid[] SEND -> Paid[]
Because the interface is sealed, the compiler knows the complete
list of states. Add a record Canceled() state to the
permits list and forget the switch, and the program no
longer compiles:
error: the switch expression does not cover all possible input
values. The two styles divide the work differently. With a class per
state, adding a state touches one place; with the switch,
adding an operation touches one place and the compiler
finds every switch that misses the new state. The
Java page on modeling variants
shows more of this style.
Template method: a fixed skeleton
Joost Bakker's nightly export at 02:30 always follows the same steps: select the invoices, format them, archive the file. Only some steps differ per receiver. A template method is a method in a base class that defines the steps of an algorithm and their order, and calls methods that subclasses fill in or may override (often called hooks):
from abc import ABC, abstractmethod
from decimal import Decimal
class NightlyExport(ABC):
"""Template method: run() fixes the steps and their order."""
def run(self, invoices):
rows = [i for i in invoices if self.include(i)]
print(f"[{self.name}] {len(rows)} of {len(invoices)} invoices selected")
for line in self.format(rows):
print(f"[{self.name}] {line}")
print(f"[{self.name}] archived as {self.file_name()}")
# Hook with a default: subclasses MAY override it.
def include(self, invoice):
return True
# Steps that subclasses MUST provide.
@abstractmethod
def format(self, rows): ...
@abstractmethod
def file_name(self): ...
class BankExport(NightlyExport):
name = "bank"
def include(self, invoice): # only direct debits go to the bank
return invoice["method"] == "direct debit"
def format(self, rows):
return [f"{r['invoice']};{r['amount']}" for r in rows]
def file_name(self):
return "kestrel-collect-2026-10-07.csv"
class LedgerExport(NightlyExport):
name = "ledger"
def format(self, rows):
total = sum(Decimal(r["amount"]) for r in rows)
return [f"TOTAL {len(rows)} invoices {total}"]
def file_name(self):
return "KESTREL_20261007.xml"
invoices = [
{"invoice": "INV-2026-104412", "amount": "118.65", "method": "direct debit"},
{"invoice": "INV-2026-104413", "amount": "96.10", "method": "bank transfer"},
{"invoice": "INV-2026-104414", "amount": "71.30", "method": "direct debit"},
]
for export in (BankExport(), LedgerExport()):
export.run(invoices)
[bank] 2 of 3 invoices selected [bank] INV-2026-104412;118.65 [bank] INV-2026-104414;71.30 [bank] archived as kestrel-collect-2026-10-07.csv [ledger] 3 of 3 invoices selected [ledger] TOTAL 3 invoices 286.05 [ledger] archived as KESTREL_20261007.xml
run() is the template method: no subclass can forget to
archive or change the order of the steps. include() is
a hook with a sensible default, and format() and
file_name() are
abstract, so a subclass that leaves them out cannot even be created.
Template method is how most frameworks work. Python's
unittest calls your setUp(), then a test
method, then tearDown(); a web framework calls your
request handler; a game engine calls your update()
sixty times a second. In each case the framework owns the flow and
calls your code at fixed points. This reversal is known as the
Hollywood principle:
"don't call us, we'll call you". Your code does not drive the
process; it is called by the code that does, which is also what
separates a framework (it calls you) from a library (you call it).
Iterator: walking through a collection
An iterator is an object
that gives access to the elements of a collection one at a time,
without revealing how the collection is stored: a list, a tree, a
database table read page by page or a file read line by line all
look the same to the loop that uses them. The pattern is so useful
that languages have built it in. Java has the
Iterator interface
behind its for-each loop, and Python has the iteration protocol
(__iter__ and __next__) behind every
for loop.
In Python you rarely write an iterator class: a generator function is an iterator. This one reads quarter-hour readings from the database in pages of three, and the caller simply loops:
def readings(ean, page_size=3):
"""Iterator as a generator: fetches page by page, yields one reading at a time."""
# Stands in for db01: 7 quarter-hour readings for one meter.
table = [("00:00", 0.21), ("00:15", 0.19), ("00:30", 0.20), ("00:45", 0.24),
("01:00", 0.18), ("01:15", 0.17), ("01:30", 0.22)]
offset = 0
while True:
page = table[offset:offset + page_size]
if not page:
return
print(f" (fetched rows {offset + 1}-{offset + len(page)} for {ean})")
yield from page
offset += page_size
# The caller just loops; paging is the iterator's business.
for quarter, kwh in readings("E-0412"):
if kwh > 0.23:
print(f"{quarter} {kwh:.2f} kWh: above 0.23, stopping early")
break
print(f"{quarter} {kwh:.2f} kWh")
(fetched rows 1-3 for E-0412) 00:00 0.21 kWh 00:15 0.19 kWh 00:30 0.20 kWh (fetched rows 4-6 for E-0412) 00:45 0.24 kWh: above 0.23, stopping early
The output shows the benefit: the loop stopped after the fourth reading, so the third page was never fetched. A generator only does the work for the elements that are actually used, which matters when a meter has 35,040 quarter-hour readings a year.
Where you already use them
Most developers use these patterns long before they learn the names. Recognizing them helps when you read a framework's documentation or someone else's code:
| Pattern | Everyday example | Python shortcut |
|---|---|---|
| Strategy |
sorted(key=...), Java's
Comparator, choosing a retry policy
|
pass a function |
| Observer |
addEventListener, change notifications in user
interface frameworks, signal handlers
|
a list of callables |
| Command | undo in an editor, job queues, database migrations | a function, or an object when it must be stored |
| State | TCP connection states, order and ticket workflows | a class per state, or a transition table |
| Template method |
unittest's setUp and
tearDown, framework request handlers
|
a function that takes the varying steps |
| Iterator |
every for loop, file objects, database cursors
|
a generator |
The other five behavioral patterns of the GoF book are less common in business code. Chain of responsibility passes a request along a chain of handlers until one deals with it, as web middleware does. Mediator lets objects talk through one central object instead of to each other. Memento saves an object's state so it can be restored. Visitor adds operations to a fixed set of classes, and is largely replaced by pattern matching in modern Java, C# and Python. Interpreter represents a small language as a tree of objects.