Strategy Pattern
The checkout function began with one payment provider and a single call to it.
Six months later it handles four — a saved card, PayPal, a crypto gateway, and
store credit — and that call has become a ladder of if branches. Every payment
change now lands in the same function: a new branch, new imports, new mocks in a
test file that is mostly about payment providers even though the function is
about orders. It became the most dangerous function in the codebase one
reasonable commit at a time.
Read the branches side by side, though, and they are the same shape: take an order, talk to somebody, come back with a receipt. Same input, same output, different middle. Think of a cordless drill — one motor, one trigger, one chuck. What the hole looks like is decided entirely by the bit clamped into it, and the drill has never had to know a masonry bit from a spade bit, only that whatever is in there has a shank that fits. The strategy pattern is that chuck written down: a family of interchangeable algorithms behind one interface, with the choice of which one made somewhere else.
The ladder and what it costs
Nothing is wrong with an if. Two branches that will stay two branches are
cheaper as a conditional than as three files, and reaching for a pattern there
makes the code longer without making it clearer.
The cost arrives with the third branch, and it takes three forms. First, every new algorithm requires editing a function that already worked, which is exactly the risk the open/closed principle warns about — code should be open to new behavior and closed to modification, because modifying working code is how working code stops working. Second, the function collects dependencies that belong to none of its callers: checkout now imports a crypto SDK, and every test that touches checkout has to stub it.
The third is the one that actually decides the question. Watch for the same
condition appearing in more than one place — the refund path branching on
payment method, the receipt formatter branching on it again, the nightly
reconciliation job branching on it a third time. That is duplicated knowledge,
and it means adding a provider is no longer one change but a hunt for every
ladder that needs a matching branch. Count it: checkout, refunds, receipts and
reconciliation, each branching over four providers, is sixteen branches that
have to agree, and a fifth provider means four edits in four files with nothing
to tell you if you missed one. A single if is a decision. The same if
in four files is a design.
One interface, three implementations
The pattern has three parts, and each has a plain job.
The strategy interface is the shape every algorithm agrees to — here, one method that takes an order and resolves to a receipt. It is the shank that fits the chuck, and it is the only thing the caller is allowed to depend on.
The concrete strategies are one class per algorithm. Each holds exactly what it needs and nothing else: the card implementation knows about the card gateway, the PayPal implementation knows about redirects and capture, and neither knows the other exists.
The context is the object that holds a strategy and delegates to it. In this example that is checkout, and the important property is negative — checkout contains no branch on payment method, because it never learns which implementation it is holding.
In the scene below, checkout holds a single reference into a row of interchangeable implementations. Follow the second hop of each step: the reference moves along the row while the box above it never changes, and the receipt comes back up through the same interface it went out through. The badge on each strategy counts the orders it has taken.
Predict first
The scene sends 8 orders through one checkout, paid in this order: card, PayPal, card, crypto, crypto, PayPal, card, PayPal. Before each one, checkout is handed the strategy that matches the method, and it calls pay on whatever it holds. Across all 8 orders, how many times does code inside checkout check which payment method an order uses?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
one order, then its receipt · a badge counts the orders that strategy paid
One checkout, one reference, three swappable implementations — use Next step to go one at a time.
The second order is the one to watch. Its method is different from the first, so a different object gets called — but no code inside checkout ran differently, because there is no code inside checkout that could. The fifth order is the mirror image: the method repeats, the same object is called again, and still no branch was evaluated anywhere.
Written out, the whole pattern is an interface, two small classes, and a context that delegates:
type Order = { id: string; method: string; amountCents: number };
type Receipt = { orderId: string; provider: string };
interface PaymentStrategy {
pay(order: Order): Promise<Receipt>;
}
class CardStrategy implements PaymentStrategy {
async pay(order: Order): Promise<Receipt> {
return { orderId: order.id, provider: 'card' }; // after the gateway charge
}
}
class PayPalStrategy implements PaymentStrategy {
async pay(order: Order): Promise<Receipt> {
return { orderId: order.id, provider: 'paypal' }; // after redirect and capture
}
}
class Checkout {
constructor(private strategy: PaymentStrategy) {}
use(strategy: PaymentStrategy): void {
this.strategy = strategy;
}
complete(order: Order): Promise<Receipt> {
return this.strategy.pay(order);
}
}
use is the swap the scene keeps making: one assignment, after which complete
runs a different algorithm without having been edited. Adding a crypto provider
is a new class plus one line where strategies are chosen — Checkout is not
opened, not re-tested, and not re-read.
Try it
Fall back to a backup gateway
A few times a year the card gateway is unreachable, and on those days you want
orders paid through PayPal instead. Do it without opening Checkout: write a
FallbackStrategy that takes a primary strategy and a backup, and pays with the
backup only when the primary fails.
Hint
A strategy can hold other strategies. If FallbackStrategy implements
PaymentStrategy itself, checkout cannot tell it from any other. Then look
closely at the try: a rejected promise only reaches your catch if you
await it inside the try.
Solution
class FallbackStrategy implements PaymentStrategy {
constructor(
private primary: PaymentStrategy,
private backup: PaymentStrategy,
) {}
async pay(order: Order): Promise<Receipt> {
try {
return await this.primary.pay(order);
} catch {
return this.backup.pay(order);
}
}
}Checkout is unchanged and still has no branch on the method; the decision about
failure lives inside a strategy, next to the rest of the knowledge about
gateways. The await in return await is load-bearing. Without it, pay
returns the primary’s promise before that promise has failed, the try is over
by the time the rejection arrives, and the caller gets the error as if there
were no backup.
One caution that is specific to payments: an error does not always mean nothing was charged. A timeout can arrive after the primary took the money, so real code falls back only on errors that prove the charge never happened, such as a gateway it could not reach at all.
To see it work, give it a primary that always fails:
class DownStrategy implements PaymentStrategy {
async pay(order: Order): Promise<Receipt> {
throw new Error(`card gateway unreachable for order ${order.id}`);
}
}
const checkout = new Checkout(
new FallbackStrategy(new DownStrategy(), new PayPalStrategy()),
);
const receipt = await checkout.complete({
id: '42',
method: 'card',
amountCents: 2500,
});
console.log(receipt); // { orderId: '42', provider: 'paypal' }Choosing at runtime
The branch did not disappear — it moved, and it changed form. Selection collapses
into a single lookup at the edge of the system, usually a map from the method on
the request to a prepared instance: const strategy = STRATEGIES[order.method].
That is data, not control flow, and a new provider becomes a new entry in a
table rather than a new branch in a function.
Because the choice is a reference rather than a compiled-in decision, it can
change while the program is running. A feature flag can route ten percent of
orders through a new provider. A per-tenant configuration can give enterprise
customers invoicing while everyone else pays by card. A circuit breaker can drop
to a backup gateway when the primary starts timing out, and none of that touches
the code that calls pay.
One honest simplification: a strategy with a single method is a function. In
TypeScript, type PaymentStrategy = (order: Order) => Promise<Receipt> gives
you the same substitutability with less ceremony, and it composes better — you
can wrap one in retry logic without writing a decorator class. Reach for the
interface when the strategies need to carry configuration or expose more than
one method; otherwise, the function is the pattern.
When to leave it alone
The case for leaving it alone is the common one: there is exactly one algorithm
and no evidence of a second. Strategy costs a file, an interface, a lookup
table, and a level of indirection between the call site and the code that
actually runs — paid now, for a variation that does not exist yet and may never.
Write the if, and let the second real requirement
tell you what the interface should be. Guessing at it in advance usually
produces an interface that fits only the case you already had.
The other test is the signature. Strategies have to be genuinely interchangeable: same inputs, same outputs, same meaning. If one of them needs three extra parameters, and the interface grows optional fields that only it reads, the pattern is telling you these algorithms are not variations of one thing. Forcing them behind one interface produces a signature that lies to every caller.
Finally, do not confuse strategy with the state pattern, which has the same skeleton and the opposite flow of control. A strategy is chosen from outside and knows nothing about its siblings. A state object decides, as a result of what it just did, which state comes next — the object drives its own transitions. Same diagram; entirely different question being answered.
Check yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
A report exporter writes CSV or PDF, chosen by one if in one function. Nothing else in the codebase branches on the format, and nobody has asked for a third. A reviewer suggests an ExportStrategy interface with two classes. What is the better call?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
Customers on the enterprise plan pay by invoice, and everyone else pays by card. You already have InvoiceStrategy and CardStrategy. Where should the choice between them be made?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
The crypto strategy needs an API key and the merchant's wallet address. The card strategy needs neither. Where should those two values go?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
An order moves from placed to paid to shipped, and what cancel() does depends on the stage: refund when paid, refuse when shipped. Each stage is an object, and each one decides which stage comes next. Which pattern is this?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
Key takeaways
Strategy replaces a branch on behavior with a reference to behavior, so the calling code stops needing to know what the options are.
The signal to extract strategies is not the number of branches but the same condition appearing in more than one place.
Selection moves to one lookup at the edge of the system, which is what makes runtime swapping — flags, per-tenant config, fallbacks — possible.
With one algorithm and no second in sight, the interface is speculation: write the conditional and let the second requirement design the seam.