Software Design Reference Reference · Patterns

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 haveThe patternLesson
One behaviour varies; the caller shouldn't.Strategy0004
Their interface isn't the one you need.Adapter0008
Four calls always happen in the same order.Facade0008
Retry/cache/logging is creeping into callers.Decorator0021
Technology is defining what the domain looks like.Ports & Adapters0009
Nobody can find where things get constructed.Composition root0023
Two primitives travel together and drift apart.Value object0012
A rule spans objects and nobody enforces it.Aggregate0012
The domain is writing SQL.Repository0013
Two writes must succeed or fail together.Unit of work0013
Another team's words are leaking into your model.Anti-corruption layer0036
One event should trigger four unrelated reactions.Message bus0025
The dataset no longer fits in memory.Generator pipeline0018
Domain tests need an event loop.Async boundary0024
A large system must be replaced without a rewrite.Strangler fig0035
Forty call sites must move to a new component.Branch by abstraction0035
A column must be renamed in live data.Expand / contract0035
The code is ready; the users are not.Feature flag0035
It works and you don't know why.Characterization test0005

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.

Taught in: lessons 0004, 0008, 0016

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.

Taught in: lessons 0008, 0023

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.

Taught in: lessons 0021, 0034

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.

Taught in: lessons 0009, 0023

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.

Taught in: lessons 0019, 0023

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.

Taught in: lessons 0013, 0025

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.

Taught in: lessons 0005, 0037

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.