Strategy is the pattern you will use most. It replaces a conditional that selects between interchangeable behaviours with a set of objects — or functions — sharing one shape, chosen by the caller.
It is also the pattern most often introduced before its problem exists, which is why the second half of this topic is about when not to.
The problem
function discountFor(order, type) {
if (type === 'percentage') return order.total * 0.9;
if (type === 'fixed') return order.total - 10;
if (type === 'bogo') return order.total - cheapestItem(order);
if (type === 'tiered') return order.total * tierRate(order.customer);
return order.total;
}Nothing is wrong with this until the fifth rule, and then several things are:
- Every new rule edits a function that already works, so a mistake in the new branch can break existing ones.
- The rules cannot be tested in isolation. Testing tiered pricing means calling through the dispatcher.
- They cannot be composed, listed, or given metadata. "Show the customer which discounts applied" needs a loop over things, and there are no things.
- The function grows without limit, and eventually a branch grows its own sub-branches.
The pattern
Each behaviour becomes a value with the same shape; the caller selects one.
const discounts = {
percentage: (order) => order.total * 0.9,
fixed: (order) => order.total - 10,
bogo: (order) => order.total - cheapestItem(order),
tiered: (order) => order.total * tierRate(order.customer),
};
const discountFor = (order, type) => (discounts[type] ?? ((o) => o.total))(order);classDiagram
class Checkout {
-discount: DiscountRule
+total(order) Money
}
class DiscountRule {
<<interface>>
+apply(order) Money
}
class PercentageDiscount {
+apply(order) Money
}
class TieredDiscount {
+apply(order) Money
}
DiscountRule <|.. PercentageDiscount
DiscountRule <|.. TieredDiscount
Checkout --> DiscountRuleIn a language with first-class functions, Strategy is usually a lookup of functions and needs no classes at all. The class version earns its place when a strategy has state, needs several related operations, or carries metadata — a name to display, a predicate for whether it applies.
What it actually buys
Adding a rule stops editing working code. This is the Open/Closed principle made concrete, and it is the main benefit.
Each rule is testable alone. bogo(order) with a constructed order, no dispatcher involved.
Rules become data. You can list them, filter them, order them, load them from configuration, or let a non-engineer enable one. A conditional cannot do any of that, and this is often the requirement that arrives later and forces the refactor anyway.
The dispatch disappears from the caller. checkout.total() does not know how many rules exist.
When not to use it
One implementation. An interface with a single implementation is a function wearing a costume. Wait for the second — and preferably the third, since two cases cannot tell you which differences are meaningful.
The branches are not interchangeable. Strategy requires the same input and the same kind of output. If one branch needs a customer, another needs a date range, and a third returns a different type, they are not variants of one operation — forcing them into one interface produces parameters that only one implementation uses.
The conditional is stable. A two-branch check that has not changed in three years is not a growth problem. Strategy is a response to change, not to the presence of an if.
The behaviour is a single expression and always will be. A lookup table of three one-liners is already the pattern; adding classes around it is ceremony.
Strategy and its neighbours
State has the same structure and a different intent: the object changes which strategy it uses itself, based on what happens to it, and transitions are part of the design. Strategy is chosen from outside and does not change itself.
Template Method solves a related problem by inheritance: the base class owns the sequence and subclasses fill in steps. Strategy hands over the whole behaviour. Template Method binds at compile time and can enforce an ordering invariant; Strategy binds at runtime and cannot.
Chain of Responsibility picks the first handler that applies. Strategy assumes the caller already knows which one it wants.
Check your understanding
Common mistakes
Strategy with one strategy. An interface with a single implementation is indirection with nothing on the other side.
Forcing non-interchangeable branches into one interface. Signatures become the union of everything, and every implementation ignores most of it.
Building classes where a function would do. In a language with first-class functions, most strategies are functions.
Leaving the selection scattered. If several places map a key to a strategy, they will disagree. Select once, at the composition root.
Strategies that reach back into the caller. A strategy needing the caller's internals is not interchangeable — it is a method that was moved.
Confusing it with State. If the object changes its own behaviour based on what happens to it, that is State and the transitions matter.
No default. An unrecognised key returning undefined fails far from the cause. Provide a default or throw at selection time.
Practice
A shipping cost function has grown to 12 branches over country, weight band and carrier, with nested conditions. Design the refactor, and say what you would do first.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Twelve branches over three variables is probably not one strategy dimension.
Show a strong answer
The first move is not a pattern — it is characterisation tests. Twelve nested branches over three variables is code nobody fully understands, and the refactor is worthless if it silently changes a price. I would generate a table of inputs across the boundaries — each country, each weight band edge, each carrier — record current outputs, and lock them in. That table is also the specification nobody wrote.
Then diagnose the dimensions before choosing a structure. Twelve branches over three variables is a strong hint that this is not one axis of variation. Reading them usually reveals something like: carrier determines the algorithm, weight band determines parameters within it, and country determines which carriers are available plus a tax rule. Those are three different kinds of thing, and collapsing them into one strategy interface is how the refactor produces something as tangled as what it replaced.
The structure I would expect to end up with:
- A strategy per carrier, since each genuinely computes cost differently. This is the real Strategy, and it is the smallest set that is interchangeable — same input, same output.
- Weight bands as data, not code. A table of thresholds and rates per carrier, because bands change frequently and for business reasons. Encoding them as branches guarantees an engineer is involved every time a rate changes.
- Country handled as selection plus a separate tax step, not as part of the cost algorithm. It answers "which carriers may I use here" and "what is added afterwards" — different questions from "what does this carrier charge".
Why I would resist a single ShippingStrategy keyed on all three: it would have as many implementations as the branch count, duplicating the shared arithmetic across them. The point of the split is that the three variables vary independently and should not be multiplied.
Sequencing it safely:
- Extract one carrier first, leaving the conditional in place and delegating that one case. The characterisation tests confirm nothing moved.
- Then the rest, one at a time, deleting each branch as it is replaced.
- Then lift the weight bands out to data, which is a separate change with its own risk and its own review.
What I would check afterwards: that adding a carrier touches one new file, and that changing a rate touches no code at all. If either is false, the split was along the wrong lines.
A colleague has written a `SortStrategy` interface with one implementation, arguing 'we will need more later'. Write the review response, including what you would accept.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Argue about cost and evidence, not about principle — and offer something concrete.
Show a strong answer
Open by conceding the goal. They are anticipating change, which is the right instinct; the disagreement is about whether acting now improves the outcome. Framing it as "you are over-engineering" invites a defence of the principle instead of a discussion of this case.
The argument, in cost terms:
- The interface is a guess with one data point. Whatever the current implementation needs — a comparator, a stability guarantee, a particular return shape — is now the contract, and there is no way to know which of those are essential. When the second sort arrives it will differ in some way nobody predicted.
- Reshaping later is more expensive than extracting later. Extracting an interface from one class is a mechanical refactor most tooling does automatically. Reshaping a wrong interface touches the interface, both implementations and every caller.
- The reader pays now for a benefit that may never arrive. Every reader follows an extra hop to find the one thing that runs.
The evidence question that usually settles it: is the second implementation funded and scheduled, or anticipated? A start date is a fact and changes my answer completely. "We will need more later" is a forecast, and forecasts about which abstractions we will want are famously poor.
What I would accept, so the review is not just a no:
- Keep the sorting behind one narrow function with a name from the domain —
orderResults(items)rather than callers inlining a comparator. Containment is cheap, always pays, and is what makes the later extraction a small change. - Do not leak sort-specific vocabulary to callers. If callers pass comparators around, extraction later is painful; if they call one named operation, the internals are free to change.
That is the distinction I would want them to take away: encapsulation now, abstraction later.
And I would say what would change my mind, so it reads as a judgement rather than a rule: a second sort in the next sprint, a public API where changing the shape later is a breaking change for other teams, or a genuine need to test the caller with a fake sort. Any of those makes the interface correct today.
Key takeaways
- Strategy replaces a conditional over interchangeable behaviours with values sharing one shape.
- In languages with first-class functions it is often a lookup of functions — classes earn their place when a strategy has state, several operations, or metadata.
- The durable benefits: new variants are additions not edits, each is independently testable, and behaviours become data you can list, order and configure.
- It relocates the conditional to one place rather than removing it. Overclaiming makes the pattern easy to argue against.
- Interchangeability is a precondition. Different inputs and outputs mean these are separate operations, not variants.
- Wait for the second implementation. An interface extracted from one is a guess, and a wrong one costs more than the duplication.
- Distinguish from State (the object changes its own behaviour), Template Method (base class owns the sequence) and Chain of Responsibility (first applicable handler wins).
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- Martin Fowler, Refactoring, second edition (Addison-Wesley, 2018) — replace conditional with polymorphism.
- Sandi Metz, Practical Object-Oriented Design in Ruby (Addison-Wesley, 2012).
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — characterisation tests before refactoring.
- Bertrand Meyer, Object-Oriented Software Construction, second edition (Prentice Hall, 1997) — the Open/Closed principle.