Reference document · quick lookup
Patterns Quick Reference
Patterns are names for recurring structures that solve recurring problems. Learn to recognise them emerging in code before you learn to apply them deliberately — which is why every entry here leads with the problem, not the shape. Terms follow the glossary; each pattern names the lesson that teaches it.
Find the pattern by the problem
Patterns are answers. Read this column first — if none of these is your problem, none of these is your pattern.
| The problem you have | The pattern | Lesson |
|---|---|---|
| One behaviour varies; the caller shouldn't. | Strategy | 0004 |
| Their interface isn't the one you need. | Adapter | 0008 |
| Four calls always happen in the same order. | Facade | 0008 |
| Retry/cache/logging is creeping into callers. | Decorator | 0021 |
| Technology is defining what the domain looks like. | Ports & Adapters | 0009 |
| Nobody can find where things get constructed. | Composition root | 0023 |
| Two primitives travel together and drift apart. | Value object | 0012 |
| A rule spans objects and nobody enforces it. | Aggregate | 0012 |
| The domain is writing SQL. | Repository | 0013 |
| Two writes must succeed or fail together. | Unit of work | 0013 |
| Another team's words are leaking into your model. | Anti-corruption layer | 0036 |
| One event should trigger four unrelated reactions. | Message bus | 0025 |
| The dataset no longer fits in memory. | Generator pipeline | 0018 |
| Domain tests need an event loop. | Async boundary | 0024 |
| A large system must be replaced without a rewrite. | Strangler fig | 0035 |
| Forty call sites must move to a new component. | Branch by abstraction | 0035 |
| A column must be renamed in live data. | Expand / contract | 0035 |
| The code is ready; the users are not. | Feature flag | 0035 |
| It works and you don't know why. | Characterization test | 0005 |
Structural patterns
Shapes that decide who talks to whom.
Strategy
Problem: one behaviour varies (pricing, shipping, discount) while its context stays stable.
Shape: an interface with one method; several implementations; the context holds one and delegates. In Python the interface can simply be a callable.
Discount = Callable[[Money], Money] # the interface, at its cheapest
def final_price(total: Money, discount: Discount) -> Money:
return total - discount(total) # the context never names a variant
Use when: you see an if/elif chain selecting behaviour, or a class with one method that branches on a type field.
Don't: force it for a single variation you could pass as a function — and don't extract at two branches with no third in sight.
Adapter
Problem: a client needs interface A; a service speaks interface B, and B is not yours to change.
Shape: a wrapper that implements A and translates calls into B.
class PaymentGateway(Protocol): # your interface, your words
def charge(self, amount: Money, token: str) -> str: ...
class StripeAdapter: # implements yours, speaks theirs
def charge(self, amount: Money, token: str) -> str:
return self._client.payment_intents.create(...).id
Use when: integrating third-party libraries, old code, or anything whose interface you don't control.
Don't: let the adapter leak B's quirks — its error types, its status codes, its vocabulary — through to the client.
Facade
Problem: clients must orchestrate several subsystems to do one recurring thing.
Shape: one class with a few coarse methods that drive the subsystems underneath.
class CheckoutFacade:
def place_order(self, cart: Cart, token: str) -> Order:
self._inventory.reserve(cart) # one coarse method…
charge = self._payments.charge(cart.total, token)
return self._shipping.create(cart, charge) # …four subsystems hidden
Use when: a setup/teardown sequence recurs across callers; you want one stable face over volatile parts.
Don't: give the facade the subsystems' fine-grained methods — that is relocation, not simplification.
Taught in: lesson 0008
Decorator (wrapper)
Problem: a cross-cutting concern — retry, cache, timing, audit — must apply to one collaborator without entering the domain.
Shape: a class implementing the same interface it wraps, delegating to the inner object and adding behaviour around it.
class RetryingGateway: # same port in, same port out
def __init__(self, inner: PaymentGateway, attempts: int = 3) -> None:
self._inner, self._attempts = inner, attempts
def charge(self, amount: Money, token: str) -> str:
... # retry here; the caller sees no change
Use when: you would otherwise sprinkle the same try, the same cache lookup, or the same timer through every caller.
Don't: stack five decorators whose order matters and is written down nowhere; the wiring becomes the behaviour.
Ports & Adapters (hexagonal)
Problem: the domain must not know about databases, APIs, files — or the clock.
Shape: the domain defines ports (the interfaces it needs); the outside world supplies adapters implementing them; dependency arrows point inward.
# domain/ports.py — everything the core needs from the world
class Clock(Protocol):
def now(self) -> datetime: ...
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
Use when: the domain is the asset and the technology around it is expected to change, or must be faked in tests.
Don't: add a port for every class. Ports exist at the application boundary, not between every pair of objects.
Composition root
Problem: construction of concrete things is scattered, so swapping one means hunting through the codebase.
Shape: one visible function, near the entry point, where every adapter is constructed and injected. Nothing else names a technology.
def build_app() -> App: # the only function that says "Stripe"
clock: Clock = SystemClock()
orders: OrderRepository = SqlAlchemyOrderRepository(make_session())
return App(checkout=CheckoutService(orders, StripeAdapter(stripe), clock))
Use when: always, once there is more than one adapter — it is what makes “separate creation from use” real.
Don't: let it become a service locator that objects reach into; the root pushes dependencies, it is never pulled from.
Domain patterns
Shapes that keep the rules true.
Value object
Problem: a concept with no identity (money, address, interval) is being passed around as loose primitives.
Shape: an immutable class with equality by value — usually a @dataclass(frozen=True) — that validates itself on construction.
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self) -> None:
if self.amount < 0: # a broken Money cannot be built
raise ValueError("amount cannot be negative")
Use when: you catch yourself passing (amount, currency) pairs, or validating the same tuple in ten places.
Don't: make entities value objects — identity-bearing things are not interchangeable.
Taught in: lesson 0012
Aggregate
Problem: several objects must change consistently, and no single object is in charge of the rule.
Shape: a cluster with one root; all external changes go through the root, which enforces the invariants of the whole cluster.
class Order: # the root guards the cluster
def add_line(self, product: ProductId, qty: int, price: Money) -> None:
if self._status is not Status.PENDING:
raise OrderLockedError() # nobody reaches into _lines directly
self._lines.append(OrderLine(product, qty, price))
Use when: a rule spans objects and must hold after every change — “an order cannot be edited once confirmed”, “a customer with open orders cannot be deleted”.
Don't: make the whole object graph one aggregate; that serialises every change and kills concurrency.
Taught in: lesson 0012
Repository
Problem: the domain needs to load and save aggregates without knowing how they are stored.
Shape: a collection-like interface — get, add, delete, plus queries phrased as domain questions — implemented by a storage adapter.
class OrderRepository(Protocol):
def get(self, order_id: str) -> Order: ...
def add(self, order: Order) -> None: ...
def get_unshipped(self) -> list[Order]: ... # a question, not a filter
Use when: storage is a detail that will change, or must be replaced by a fake in tests.
Don't: expose the ORM's query API through the repository; the leak test is whether an in-memory fake is a drop-in.
Taught in: lesson 0013
Unit of work
Problem: several repository operations must commit or roll back together, and the domain must not learn that transactions exist.
Shape: an object owning the transaction boundary, exposing the repositories that live inside it.
with uow: # one transaction boundary
order = uow.orders.get(order_id)
order.confirm()
uow.commit() # all of it, or none of it
Use when: any write that touches more than one aggregate, or more than one repository.
Don't: commit inside repositories — once each one commits itself, nothing can be grouped.
Anti-corruption layer
Problem: another context or vendor has a model whose words mean different things, and it is leaking into yours.
Shape: a translation layer at the boundary: their payload in, your domain objects out. Only your vocabulary crosses inward.
def to_domain(payload: dict) -> Shipment: # their words in, ours out
return Shipment(
tracking=TrackingCode(payload["trkNo"]),
status=STATUS_MAP[payload["st"]], # "st" never reaches the domain
)
Use when: integrating a legacy system, a partner API, or another team's bounded context.
Don't: share a DTO package between two contexts — that is the same leak, with a build dependency added.
Taught in: lesson 0036
System patterns
Shapes for data and time.
Message bus / event-driven core
Problem: components must react to each other without knowing each other.
Shape: a bus routing events (facts that happened) or commands (intentions) to registered handlers.
class MessageBus:
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
bus.register(OrderConfirmed, send_confirmation_email) # wiring, not domain
Use when: a workflow has many side effects — “when an order is confirmed: email, invoice, stock, metrics”.
Don't: use it where a direct call is clearer; the bus buys decoupling by hiding the flow.
Taught in: lesson 0025
Generator pipeline
Problem: processing large or unbounded data without loading it all into memory.
Shape: lazy generator stages chained together — iterable in, items out — each holding one item at a time.
def error_entries(lines: Iterable[str]) -> Iterator[LogEntry]:
for line in lines: # iterable in, items out
entry = parse(line)
if entry.level == "ERROR":
yield entry
total = summarize(error_entries(read_lines("app.log"))) # flat memory
Use when: streaming files, API pages, or infinite sequences; and wherever the stages should stay separately testable.
Don't: chain generators where a list comprehension reads better and memory is not the constraint.
Taught in: lesson 0018
Async boundary
Problem: I/O concurrency keeps leaking into business logic, so every rule needs an event loop to test.
Shape: the domain stays sync and pure; an async service layer wraps it; await lives only where I/O does.
async def refund_order(order: Order, gateway: AsyncGateway) -> Refund:
result = await gateway.refund(order.total) # I/O at the edge
return apply_refund(order, result.refund_id) # sync rule, no loop needed
Use when: you want domain logic testable without event loops, and callable from a script or a cron job.
Don't: sprinkle async through the whole stack because one endpoint is slow.
Taught in: lesson 0024
Evolution patterns
Shapes for changing a system that is already running.
Strangler fig
Problem: replace a large working system without a rewrite.
Shape: intercept traffic at the edge, route new requests to the new implementation, grow its coverage, delete the old in slices.
def route(request: Request) -> Response:
if request.path in MIGRATED: # the fig grows one route at a time
return new_system.handle(request)
return legacy.handle(request)
Use when: the old system works and is large; a rewrite is a second-system temptation.
Don't: run the fig forever — the pattern is finished only when the last route moves and the old system is deleted.
Taught in: lesson 0035
Branch by abstraction
Problem: replace a component used in dozens of places, in small reviewable merges.
Shape: introduce an interface, migrate all users to it, swap the implementation behind it, delete the old one.
class StockService(Protocol): # 1. insert the seam
def reserve(self, sku: str, qty: int) -> None: ...
# 2. migrate all 40 callers to the seam 3. swap the implementation
# 4. delete the old one — every step small, reviewable, reversible
Use when: the component has many call sites and you want every step mergeable and reversible.
Don't: skip step two — migrating callers before the swap is what keeps each merge small.
Taught in: lesson 0035
Expand / contract
Problem: rename, retype or drop a column that live code is reading and writing.
Shape: four deploys — expand (write both), backfill, switch reads, contract (stop writing, then drop).
# 1. expand add `delivery_address`; write BOTH; keep reading `address`
# 2. backfill copy history in idempotent batches
# 3. switch read `delivery_address`; still writing both
# 4. contract stop writing `address`; only then drop it
Use when: any schema change to data that already exists; it is the only shape that stays reversible at every step.
Don't: compress it into one deploy because the change “looks small”; that is the irreversible version.
Taught in: lesson 0035
Feature flag
Problem: code must reach production before its behaviour should reach users.
Shape: a runtime switch separating deploy from release, so rollback is a flip rather than a revert.
def checkout(cart: Cart, flags: FeatureFlags) -> Order:
if flags.is_enabled("new_pricing"): # deployed, not yet released
return new_pricing_checkout(cart)
return legacy_checkout(cart)
Use when: dark launches, staged rollouts, and any change you want to be able to undo in seconds.
Don't: let it become permanent — a flag that stops varying is config debt and an extra branch forever.
Taught in: lesson 0035
Seam / characterization test
Problem: change legacy or AI-generated code you do not trust.
Shape: find or create a seam, lock the current behaviour with tests, then restructure behind them.
def test_characterizes_current_behaviour() -> None:
# not "what it should do" — what it does today, locked before the refactor
assert legacy_total(SAMPLE_ORDER) == Decimal("118.60")
Use when: anything where “it works, but I don't understand it” — including code generated five minutes ago.
Don't: write the test for what the code should do; a characterization test records what it does, so the refactor can be proved safe.
Sources: Gamma et al., Design Patterns; Freeman et al., Head First Design Patterns; Evans, Domain-Driven Design; Vernon, Implementing DDD; Percival & Gregory, Architecture Patterns with Python; Cockburn, hexagonal architecture; Fowler, Strangler Fig, Branch by Abstraction and Parallel Change; Feathers, Working Effectively with Legacy Code.