Engineering Atlas › Low-Level Design

Choosing a pattern for your problem

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

  1. A stated requirement. "We are adding DHL next quarter" is direct evidence for a carrier seam.
  2. Repeated conditionals. The same switch carrier in 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.
  3. Change history. If eleven of the last fifteen commits touching billing.py were "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.
  4. 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).
  5. 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.

  1. Inline code. One variant. Write it directly.
  2. A conditional. Two variants used in one place. An if or switch is the clearest code there is, and many compilers check that it is exhaustive.
  3. 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.
  4. An interface with classes. Variants carry state, or have several related operations that must stay consistent (a carrier has quote, label and tracking_url). Grouping them in one class means adding a carrier is adding one file.
  5. 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

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

  1. Change is the dominant cost of software over its lifetime, and it arrives along a few axes, so designs isolate those axes.
  2. Every seam has a cost in indirection, reading time and sometimes runtime, so seams go only where change is likely.
  3. A seam that makes one axis cheap makes another expensive (the expression problem), so the choice must follow the axis that actually moves.
  4. Predictions about change are often wrong, so evidence (requirements, repeated conditionals, change history) beats intuition, and the cheapest rung that works is preferred.
  5. 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" --> B

Worked 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

OptionStrengthsWeaknessesChoose when
Direct code or a single switchClearest; exhaustiveness checks; inlinableEach new variant edits every switchOne or two variants, one or two sites
Table of dataOne line per variant; no codeOnly works when variants differ in valuesRates, limits, labels, form types
Map of functionsAdd variants as data; smallNo per-variant state; no compile-time exhaustivenessStateless variants of one behaviour
Interface with classesVariant logic grouped; new variant is one fileNew operations edit every class; more filesVariants grow, operations stable
Visitor or exhaustive matchNew operations are one place; compiler lists missing casesNew types edit every operationClosed set of types, growing set of operations
Plugin frameworkOther teams add variants without your codeVersioning, discovery, debugging costVariants owned outside your team

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. Five functions switch on ticket.status, and some branches set ticket.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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →