Choosing a pattern for your problem
Open in the interactive app: practice, quiz, mock interview →In one minute
- A pattern is a bet that a specific thing will change. Choose one by naming the axis of change first ("new carriers", "new order states", "new export formats"), then pick the smallest structure that makes a change along that axis touch one place.
- Look for evidence before betting: a requirement or roadmap item, the same
switchrepeated in several files, a change history where one kind of feature keeps editing the same functions, or tests that are hard to write. - Climb a ladder one rung at a time: inline code, then a function parameter, then a map of functions, then an interface with classes. Stop at the lowest rung that absorbs the known changes.
- Every structure makes one kind of change cheap and another expensive. Polymorphism makes new types cheap and new operations costly; a
switch(or Visitor) does the opposite. Choose by which axis your system actually moves along. - A wrong abstraction costs more than duplication. Add patterns when evidence arrives, and remove them when the variation they were built for never came.
How it works
Start from the change, not the catalogue
Design Patterns maps the fifteen common patterns to the problem shapes behind them. This lesson is about the decision itself: when a pattern is worth its cost, which of two similar patterns to use, and how to tell after the fact whether you chose well.
The core idea goes back to David Parnas in 1972: decompose a system so that each module hides a decision that is likely to change. A design pattern is a reusable way of hiding one kind of decision. Strategy hides which algorithm. State hides which stage. Factory Method hides which concrete type. Adapter hides whose interface. Observer hides who cares about this event. So the first question is never "which pattern fits?" but "which decision here is likely to change, and how often?" If you cannot name the decision, no pattern is justified yet.
Evidence, in order of strength
- A stated requirement. "We are adding DHL next quarter" is direct evidence for a carrier seam.
- Repeated conditionals. The same
switch carrierin rate quoting, label printing and tracking URLs means every new carrier edits three files, and sooner or later one is missed. This is the strongest signal you can find in code. - Change history. If eleven of the last fifteen commits touching
billing.pywere "add plan X", the plan is an axis of change. Git log tells you what changed together; that is better evidence than anyone's guess. - Test pain. Code that cannot be tested without a real clock, network or database needs a seam for that dependency (usually plain dependency injection, not a named pattern).
- Speculation. "We might support other databases" with no plan or date. This is not evidence. Write the direct code.
The ladder
Each rung adds indirection, so climb only as far as the evidence requires.
- Inline code. One variant. Write it directly.
- A conditional. Two variants used in one place. An
iforswitchis the clearest code there is, and many compilers check that it is exhaustive. - A function parameter or a map of functions. Variants differ in one behaviour and carry no state:
PRICERS = {"standard": standard_price, "member": member_price}. This is Strategy in its smallest honest form. - An interface with classes. Variants carry state, or have several related operations that must stay consistent (a carrier has
quote,labelandtracking_url). Grouping them in one class means adding a carrier is adding one file. - A framework or plugin system. Variants are written by other teams or loaded at runtime. Registries, plugin discovery and versioned interfaces. Expensive, and right only when the people adding variants cannot edit your code.
Joshua Kerievsky's Refactoring to Patterns describes this as moving towards a pattern (and sometimes away from one) through small, behaviour-preserving refactorings, instead of designing patterns in up front.
The rule of three
A common heuristic: the first time, write it; the second time, notice the duplication; the third time, extract. Two repeated switch statements may be a coincidence. Three, all switching on the same key with the same cases, are a design that is asking for one place to hold the variation. The practice task automates exactly this: it finds discriminators switched on in several places, reports sites that are missing a case, and suggests a table, a switch, State or Strategy.
Which axis moves: the expression problem
Take shapes and operations on them (area, render, export). With operations as methods on shape classes, a new shape is one new class but a new operation edits every class. With operations as functions that switch on shape type (or a Visitor), a new operation is one function but a new shape edits every function. This is the expression problem, named by Philip Wadler, and no ordinary object-oriented or functional structure makes both cheap.
That turns a vague choice into a concrete question. Carriers in a shipping system: new carriers arrive every quarter, operations are stable, so put operations on carrier classes (Strategy). A compiler's syntax tree: node types are stable, new passes (type check, optimise, print) arrive often, so use a Visitor or pattern matching over a closed set of types. TypeScript discriminated unions with an exhaustive switch are the second choice made explicit: the compiler tells you every place a new variant needs handling.
Telling near neighbours apart
- Strategy or State. Who changes the behaviour? If the caller chooses (pricing plan, compression level), it is Strategy. If the object moves itself between behaviours as events happen (an order going from paid to shipped), it is State. The tell in code: branches that assign the very field being switched on.
- Decorator, Proxy or Adapter. All three wrap. A Decorator always forwards and adds something (retries, metrics). A Proxy decides whether to forward (access control, lazy loading, caching). An Adapter changes the interface shape. If the wrapped and wrapping interfaces differ, it is an Adapter.
- Observer or a direct call. If the source must not know who reacts, or the set of reactors changes independently, use Observer. If there is exactly one reaction that is part of the same operation, call it directly; events make control flow harder to follow and harder to debug.
- Factory or dependency injection. If the concrete type depends on runtime data (a URL scheme, a file extension), use a factory. If it is fixed at startup by configuration, construct it once in the composition root and inject it (Dependency Injection Pattern).
- Command or a function. If actions must be stored, queued, retried, logged or undone, they need to be data: Command. If they only need to be passed around and called, a function value is a command already.
- Table or polymorphism. If variants differ only in values (rates, limits, labels), use a table of data. Classes for data that differs only in numbers are pure ceremony.
Boundaries are the safest bet
One place where a seam almost always pays: the boundary with something you do not control. A payment provider, an email vendor, a cloud SDK. Their API will change on their schedule, you will want a fake in tests, and you may switch vendors. An Adapter (or a small interface you own) at that edge is cheap and is justified by the boundary alone.
Removing patterns
Patterns should leave as readily as they arrive. A Strategy interface with one implementation after two years, a Factory that always builds the same class, an Observer with one subscriber: inline them. Sandi Metz's advice applies: duplication is far cheaper than the wrong abstraction. A wrong abstraction attracts conditionals (if strategy.name == "legacy") that defeat its purpose, and every caller has to learn it.
Choosing with a coding agent
Agents pattern-match on vocabulary: "make it extensible" yields factories and abstract base classes, while "add DHL" next to a repeated switch yields another branch in each place, possibly missing one. Give agents the decision in concrete terms: "carriers vary; create a Carrier interface with quote, label, tracking_url, one class per carrier, and a map from code to carrier; replace the three switches". Then review that the axis named in the prompt is the one the structure isolates.
The same shape elsewhere
Choosing a pattern is choosing where to pay for change, the same decision made in schema and API design. A database schema with a column per attribute makes new queries cheap and new attributes expensive (a migration); a key-value attribute table does the opposite. Kubernetes custom resources exist because the core API could not anticipate every type of object, so it opened that axis. Every case names the axis, puts a seam there, and accepts the cost on the other axes.
Down the stack
Layer 1The contract
A well-chosen pattern makes a precise promise: a change along its named axis touches one module, and no existing tests change. "Adding a carrier means adding one class and one registry entry" is testable: count the files in the pull request. A Strategy or State promises substitutability (every implementation honours the interface's contract, Liskov's rule). A conditional promises exhaustiveness when the language checks it. If a change along the named axis still touches five files, the pattern is not delivering its contract, whatever it is called.
Layer 2The mechanism
There are three ways to dispatch on a variant, and every behavioural pattern uses one. Conditional dispatch: a switch compiled to a sequence of comparisons or, for dense integer cases, a jump table; TypeScript's never check and Rust's match make it exhaustive, and Go needs a linter such as exhaustive to do the same. Virtual dispatch: an interface call through a vtable in Java and C++, an itab in Go (an interface value is two words: type and method table pointer, and data pointer), or a prototype lookup with an inline cache in V8. Table dispatch: a hash map from key to function or object, which is what factory registries and strategy maps are. The expression problem follows from these: conditional dispatch keeps an operation's variants together; virtual dispatch keeps a type's operations together; table dispatch lets you add variants at runtime but loses compile-time exhaustiveness.
Layer 3The machine
A switch on a small dense integer becomes a bounds check plus an indirect jump through a table, about a nanosecond when predicted. A virtual call loads the method pointer (often an L1 hit, about 1 ns) and makes an indirect call; a mispredicted indirect branch costs roughly 15 to 20 cycles. The bigger cost is lost inlining: a compiler cannot inline through a call it cannot resolve, although JITs and Go's profile-guided optimisation (since Go 1.21) devirtualise hot interface calls that almost always hit one type. A map lookup hashes the key and probes a bucket, about 20 to 50 ns for a short string key. Allocation matters more than dispatch: a Strategy object created per request in Go escapes to the heap if stored in an interface, about 25 ns plus GC pressure, while a stateless strategy kept in a package-level map costs nothing per call. None of these numbers decides a design at 1,000 requests a second; they matter in inner loops running millions of times, where a switch or a table usually wins.
Why it's built this way
- Change is the dominant cost of software over its lifetime, and it arrives along a few axes, so designs isolate those axes.
- Every seam has a cost in indirection, reading time and sometimes runtime, so seams go only where change is likely.
- A seam that makes one axis cheap makes another expensive (the expression problem), so the choice must follow the axis that actually moves.
- Predictions about change are often wrong, so evidence (requirements, repeated conditionals, change history) beats intuition, and the cheapest rung that works is preferred.
- Wrong abstractions are expensive to remove because callers grow around them, so patterns are added late and removed readily.
Diagram
flowchart TD
A["Name the axis of change"] --> B{"Evidence? requirement, repeated switch, history"}
B -- "none" --> C["Write direct code"]
B -- "yes" --> D{"Variants differ only in values?"}
D -- "yes" --> T["Table of data"]
D -- "no" --> E{"Which axis moves more?"}
E -- "new operations" --> F["Switch or Visitor over closed types"]
E -- "new variants" --> G{"Stateless, one behaviour?"}
G -- "yes" --> H["Map of functions"]
G -- "no" --> I{"Variant changes itself on events?"}
I -- "yes" --> S["State"]
I -- "no" --> J["Interface with classes (Strategy)"]
C -- "third repeat appears" --> BWorked example
A logistics service supports UPS and FedEx and is adding DHL. A search for switch carrier (or if carrier ==) finds four sites: rates.quote, labels.render, tracking.url and customs.form_type. Git history shows 9 of the last 20 commits in these files were carrier additions or carrier-specific fixes, and 2 incidents in the past year came from a carrier missing in one site (FedEx tracking links that went to UPS).
Apply the questions. The axis is carriers, with one addition a year and a fourth on the roadmap. Evidence: a stated requirement, four repeated conditionals and incidents caused by a missed case. Values only? No, each carrier has real logic for rates and labels, but customs.form_type only returns a constant per carrier, so that one becomes a table entry on the carrier. Which axis moves? Carriers; operations have been stable for two years. Does a carrier change itself on events? No. Result: a Carrier interface with quote, render_label and tracking_url, one class per carrier, a registry from code to carrier, and customs_form as a field.
Contrast order.status in the same codebase: five sites switch on it, and two of them assign order.status = SHIPPED inside branches. That is State, implemented as an enum plus a transition table since the status is already a database column.
Cost: the carrier call now goes through one interface call (about a nanosecond) before a carrier API round trip of 150 to 400 ms. The pull request that adds DHL touches one new file and one registry line, and a test iterates over the registry asserting every carrier implements every method, so a missed case becomes a failed build instead of an incident.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
Direct code or a single switch | Clearest; exhaustiveness checks; inlinable | Each new variant edits every switch | One or two variants, one or two sites |
| Table of data | One line per variant; no code | Only works when variants differ in values | Rates, limits, labels, form types |
| Map of functions | Add variants as data; small | No per-variant state; no compile-time exhaustiveness | Stateless variants of one behaviour |
| Interface with classes | Variant logic grouped; new variant is one file | New operations edit every class; more files | Variants grow, operations stable |
| Visitor or exhaustive match | New operations are one place; compiler lists missing cases | New types edit every operation | Closed set of types, growing set of operations |
| Plugin framework | Other teams add variants without your code | Versioning, discovery, debugging cost | Variants owned outside your team |
Pitfalls in production
- Pattern before evidence. A
NotificationStrategyFactoryfor a product that has only ever sent email. Fix: require the named axis and its evidence in the design document; inline seams with one implementation after a review period. - Wrong axis. Shapes as classes in a system where new reports (operations) arrive weekly, so every report edits twenty classes. Fix: check which axis the change history shows, and restructure along it.
- Missed case in a repeated switch. A new enum value handled in three of four places, failing in production. Fix: exhaustive checks (
neverin TypeScript, theexhaustivelinter in Go,assert_neverin typed Python) or move behaviour onto the variant. - The abstraction that attracted conditionals.
if isinstance(strategy, LegacyStrategy)inside the caller. Fix: the abstraction is wrong; inline it back to the callers, then extract the right one. - Observer as hidden control flow. Business-critical steps implemented as event subscribers, so nobody can see the order of operations and failures are silent. Fix: keep required steps as direct calls; use events for optional, independent reactions.
- Copying another system's patterns. Enterprise layering copied into a 2,000-line service. Fix: size the structure to the variation this system has.
Leadership lens
- Ask for the axis of change and its evidence in every design review that introduces a pattern. "Which future pull request does this make smaller?" is a question anyone can answer or fail to answer.
- Use change history as data. A quarterly look at which files change together and which incidents came from missed cases points to where seams belong, and where they are dead weight.
- Budget for removing abstractions, not only adding them. Unused seams are a cost every new hire and every agent pays when reading the code.
- Standardise the small forms. A team that agrees "stateless variants are a map of functions; variants with state are an interface with classes" makes consistent choices without debate.
- When directing agents, specify the structure and the axis explicitly. Agents add patterns readily and remove them rarely.
Check yourself
Three functions switch on
payment_method, and none of the branches assigns it. A fourth method is coming. What do you recommend?Show answer
Strategy: a payment-method interface (or a map of functions if the behaviour is stateless) so the new method is one new implementation, and the missing-case bug class goes away.
Five functions switch on
ticket.status, and some branches setticket.status. Why is that different?Show answer
The variant changes itself in response to events, which is State: the transitions are part of the behaviour, so they belong with the states (or in a transition table), not scattered across callers.
Your node types are fixed but you add a new analysis pass every month. Classes per node with a method per pass, or a Visitor?
Show answer
A Visitor or exhaustive pattern match, because the moving axis is operations; methods on node classes would make every new pass edit every class.
Every tax region switch just returns a rate. Which structure?
Show answer
A table of data keyed by region; the variants differ only in values, so classes or strategies would be ceremony.
Watch
- Nothing is Something (Sandi Metz, RailsConf 2015): conditionals versus polymorphism and composition, and how to tell which a problem needs.
- Stop Writing Classes (Jack Diederich, PyCon 2012): a funny, concrete case for removing abstractions that a function would replace.
Free resources
- On the Criteria To Be Used in Decomposing Systems into Modules (David Parnas, 1972): the paper that says to hide decisions likely to change, the root of every pattern choice.
- Replace Conditional with Polymorphism (Refactoring.Guru): the refactoring that turns a repeated switch into Strategy or State, with the conditions for using it.
- Is Design Dead? (Martin Fowler): how evolutionary design and refactoring change when patterns should be introduced.
- Yagni (Martin Fowler): the cost model for building flexibility before it is needed.
- Rule of three (Wikipedia)): the duplication heuristic for when to extract an abstraction.