Strategy
Behaviour that varies by a recurring kind, held in a value instead of a conditional. Worth it when the variation is real and stable — and a map of functions usually gets you there first.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
A pricing conditional appears in four places. When is that a strategy, and when is it a function I should have passed in?
Pricing differs by customer tier: standard pays list, discount gets ten percent off, premium spends loyalty credit first. Finance expects two more tiers this year, and the same tier logic already appears in checkout, quotes, invoices and the renewal job.
A switch on tier inside price(). Three cases, all visible together, and adding a tier is one case. Nobody needs an interface to read this.
The switch is correct and stays correct. What breaks is that the same switch appears in the invoice renderer, then the quote generator, then the renewal job — and adding a tier now means finding four of them with no help from the compiler (Shotgun Surgery).
- The switch is correct and stays correct. What breaks is that the same switch appears in the invoice renderer, then the quote generator, then the renewal job — and adding a tier now means finding four of them with no help from the compiler (Shotgun Surgery).
- Each branch grows its own dependencies: premium needs the loyalty ledger, discount needs the campaign table. The pricing function now depends on everything any tier needs, and so does every test of it (Testing as Design Feedback).
- The audit requirement lands badly: recording *which* rule applied means the switch must also produce a label, so every branch gains a second concern.
- The rules diverge in shape, not just in numbers. Premium needs to know the loyalty balance, which is not a parameter the other two want, and the shared signature starts accumulating optional arguments (Long Parameter List).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Tier is stored per customer and can change at runtime, so the choice cannot be compile-time.
- Finance owns the rules and changes them mid-quarter; engineering must not be the bottleneck on a rate change (Code Ownership).
- Prices must be reproducible for audit: the same order priced today and next year must be explainable (Stable Identifiers).
- Exactly one rule applies to an order, and which one applied must be recorded with the price (Explicit State).
- A price is never negative, whatever the rule computes.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each rule owns one computation and the data it needs to do it — and owns naming itself for the audit record.
- One place owns selection: tier value in, rule out. Not four places (Factory).
- The caller owns neither. It asks for a price and gets a price plus the label of what applied.
- The seam is the rule signature, and it should be the narrowest thing that serves all rules: order in, priced result out. Anything a single rule needs is fetched inside that rule, not added to the shared signature (Interface Versus Implementation).
- The selection table is the second boundary and the one that changes when finance adds a tier. Keeping it as data rather than control flow is what makes that change a one-liner (Wiring and the Composition Root).
- Rules must not reach past their boundary into each other. A rule that calls another rule is a hierarchy in disguise (Dependency Cycles).
The problem is duplication, not the conditional
This is the distinction the pattern is usually taught without. A conditional in one place is fine and will stay fine. The cost appears when the same dispatch on the same kind is written in four modules, because from then on every new kind is a search problem and the compiler is no help.
Read the code below as the observed problem, not as bad code. Someone wrote each of these four in a different sprint, each time reasonably.
1// checkout/price.ts2switch (customer.tier) { case 'standard': ... case 'discount': ... case 'premium': ... }3 4// invoices/render.ts5if (customer.tier === 'premium') showLoyaltyLine() // and the others implied6 7// quotes/estimate.ts8const rate = customer.tier === 'discount' ? 0.9 : 1.0 // premium silently wrong9 10// billing/renewal.ts11switch (customer.tier) { /* copied from checkout, six months stale */ }12 13// "Add a partner tier" now means finding all four. The third14// one is already wrong and no test noticed.The quote generator is the finding: it handles two of three kinds and defaults the third. That bug is what duplicated dispatch produces, and it is invisible in every individual file.
The pattern, and the version you should write first
Both columns below are the Strategy pattern. They have identical change costs for adding a rule, identical testability, and identical selection semantics. One of them has three files, an interface and a factory; the other has a record literal.
The class version becomes the right one at a specific point: when a rule needs to carry more than one operation — price it, explain it, decide whether it applies to this order — because a bag of related functions with shared state is what an object is for.
interface PricingStrategy { price(o: Order): Money }
class StandardPricing implements PricingStrategy { price(o) { return o.subtotal } }
class DiscountPricing implements PricingStrategy { price(o) { return o.subtotal.times(0.9) } }
class PremiumPricing implements PricingStrategy { price(o) { ... } }
class PricingStrategyFactory {
static create(tier: Tier): PricingStrategy {
switch (tier) { case 'standard': return new StandardPricing() /* ... */ }
}
}
// Four files, one method each, plus a factory whose body is
// the switch we set out to remove.type PricingRule = (o: Order) => Priced
const rules: Record<Tier, PricingRule> = {
standard: (o) => priced('standard', o.subtotal),
discount: (o) => priced('discount', o.subtotal.times(0.9)),
premium: (o) => priced('premium', o.subtotal.minus(o.loyaltyCredit)),
}
export const priceFor = (o: Order) => rules[o.tier](o)
// Adding a tier: one line in the record. Same change cost,
// no factory, and the whole rule set fits on one screen.The change costs are identical — one edit to add a rule under either — so the extra six files buy nothing that can be named. What they do buy is a real cost: four more names to learn, a factory whose body is the switch the pattern was supposed to remove, and a reader who must open three files to see three one-line rules. Escalate to the interface when a rule genuinely needs several operations or its own state; until then the record is not a shortcut, it is the same design with less of it (What an Abstraction Costs).
What it made cheap, and what it made expensive
The pattern is not free and this is where it is charged. Adding a *kind* got much cheaper; adding an *operation* to every kind got more expensive, because the shared signature is now a contract with five implementers instead of a function body with five branches.
That is the expression problem, and it is the single most useful thing to know before reaching for this pattern: ask which of the two changes your team actually gets asked for (Polymorphism).
First: finance adds a partner tier. Second: every pricing rule must also return a human-readable explanation for the invoice.
New tier: four edits, found by grep, and the quote module was already inconsistent. New operation: one edit per module — actually cheap, because the branches are already colocated.
New tier: one entry, nothing else opened, and the four call sites cannot drift because they all go through selection. New operation: every rule signature changes at once — five edits and a compile error at each, which is safe but not cheap.
How to build it
Most important first.
- Confirm the variation is real: more than one call site, more than two kinds, and a kind that recurs across functions rather than a value that varies within one (Polymorphism).
- Start with a map of functions.
Record<Tier, (o: Order) => Priced>gives you the whole pattern with no interface, no classes and no factory, and it is the correct implementation in any language with function values. - Promote to an interface only when a rule needs more than one operation — price it, *and* explain it, *and* say whether it applies. Multiple related operations is the actual trigger for an interface (Strategy).
- Return the label with the result so the audit invariant is satisfied by construction rather than by a parallel switch.
- Keep selection in one exported function, so the four call sites all go through it and adding a tier is one edit (Duplicate Knowledge).
- If the rules are genuinely data — a percentage and a threshold — do not write code at all. A table finance can edit beats three classes engineering must deploy (Deliberate Debt).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Named change one — "add a partner tier at fifteen percent": under the switch, four edits in four modules plus finding them; under the strategy, one function and one table entry, nothing else opened. This is the change the pattern exists for and it genuinely gets cheaper.
- Named change two — "rules must also produce an explanation string": under the switch, one function grows a second concern; under the strategy, the *signature changes for every rule at once*. The pattern made this change more expensive, and that is the honest half of the ledger.
- Named change three — "finance wants to change the discount rate without a deploy": neither design helps. The rate must move to configuration, which is a different decision that the pattern does not address and is often mistaken for progress.
- The map-of-functions version has the same change costs as the class-based version for all three, at a fraction of the code — which is why it is the default and the interface is the escalation.
- The behaviour is no longer in one place: reading "what does a premium customer pay" means the table then the rule, instead of one screen.
- Compile-time exhaustiveness is lost the moment selection becomes a runtime lookup, and buying it back needs a test or a sealed type (When Inheritance Fits).
- The class-based version costs three files and a factory where four lines would have done, and that cost is charged to every reader forever.
What can go wrong
- The interface is widened to the union of what all rules need, so every rule takes a loyalty balance and two of them ignore it (Leaky Abstractions).
- Selection is duplicated after all — someone writes
if (tier === "premium")in the invoice renderer for a special case — and the pattern now hides that there are two selection points. - A tier is added to the table and nothing dispatches to it, because the stored value and the table key differ by a case. Runtime failure, one customer segment, silent (Polymorphism).
- The mitigation over-corrects: rules become a plugin system with registration and discovery, for five rules that all live in one file (Plugin Architecture).
- Callers depend on the selection function and on the result type. They do not depend on the rule set, which is what makes adding a rule invisible to them.
- Each rule depends on exactly what it needs — the loyalty ledger only inside the premium rule — so the dependency fan-out that used to be the pricing function's is now distributed and honest (Fan-in and Fan-out).
- The selection table depends on every rule. That concentration is the price, and it is the right place to pay it.
- "Replace all conditionals with strategies." Only conditionals on a recurring kind, appearing in more than one place. A single local branch is a branch (Replace Conditional With Polymorphism).
- "Strategy needs an interface and classes." It needs a value that holds behaviour. In most modern languages that is a function, and the class version is a transliteration of 1994 C++ (Patterns as Vocabulary).
- "Now pricing is configurable." It is extensible by engineers, which is not the same as configurable by finance. If the requirement was a deploy-free rate change, the pattern did not deliver it.
- "The switch was bad code." The switch was correct and readable. It became expensive only when it was duplicated, and duplication is the actual problem being solved here (Duplicate Knowledge).
- shotgun-surgery
- duplicate-knowledge
Testing it, and how it ages
- Test each rule as a pure function with a hand-built order. No mocks, no container (What a Unit Is).
- One property test across all rules: price is never negative, and the returned label always matches the rule that ran (Property-Based Testing).
- Test the selection function against every tier value that exists in the database, because that is the failure dynamic dispatch introduced.
- One integration test that the price shown equals the price charged, which is the invariant no unit test covers.
- Rule sets grow until someone asks for rules that compose — a partner discount *and* loyalty credit. That is the point at which a flat strategy set stops fitting and the rules become a pipeline (Decorator).
- If the rules stabilise at three for years, the abstraction stops earning and a switch would now be simpler. Deleting a working strategy set is unpopular and occasionally correct.
- The signature is what ages. Every new rule pulls it toward the union of needs, and the healthy response is a per-rule config object rather than more parameters (Introduce Parameter Object).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- PARADIGM-SPECIFICWith first-class functions the pattern is a map of functions and effectively disappears as a structure — Python, JavaScript, Kotlin, Rust and modern Java all express it in a few lines with no interface. The class-based formulation is only necessary where a behaviour must carry several related operations, or in languages without function values at all, which is where the catalogue was written.
- DOMAIN-SPECIFICPricing, tax, shipping and commission are domains where variation by kind is genuinely open-ended and business-owned, so the pattern pays early. In domains where the kinds are fixed by an external standard — HTTP methods, ISO currency handling — the closed switch stays better indefinitely because the set cannot grow.
- CONTESTEDA serious position holds that a table of data beats a set of code strategies for almost all business rules, because a rate table can be owned, audited and changed by the people who own the rule, while a strategy set requires a deploy and an engineer. The counter is that rules that look like data reliably grow conditions — "ten percent, but not on sale items, and not above two hundred" — and a table that has grown conditionals is a worse programming language than the one you already had.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — the expression problem in full: dispatch tables make new cases cheap and new operations expensive, pattern matching over a sum type does the reverse, and no mainstream language lets you have both without extra machinery.