02 · Design Patterns

Strategy Pattern

8 min read · 5 questions · 1 exercise

The strategy pattern puts a family of interchangeable algorithms behind one interface so the code that uses them never has to know which one it got.

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?

  1. The method is read on every order, but not by checkout. It is turned into a strategy before checkout is involved, and checkout only calls pay on what it was handed.

  2. Six is how many times the strategy changes from one order to the next: every pair except crypto, crypto. Each swap is one assignment made from outside, through use, and an assignment is not a branch.

  3. Three is the number of branches the old ladder needed, one per provider. With strategies there is no ladder in checkout: complete is one line, and it is the same line for all three providers.

  4. Checkout runs this.strategy.pay(order) and nothing else, the same line for all eight orders. The badges end at 3, 3 and 2, with six swaps along the way, and none of them was decided inside checkout.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

One interface, three payment strategies

one order, then its receipt · a badge counts the orders that strategy paid

order
checkout
card
PayPal
crypto

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?

  1. Extracting later is a mechanical refactor once a real third format shows what the interface needs. Extracting now means guessing that interface from the two cases you have, and paying for a file, an interface and a lookup in the meantime.

  2. Two branches that will stay two are cheaper as a conditional, and the condition lives in one place, so there is no duplicated knowledge to remove. Let the next real requirement design the seam.

  3. Open/closed is about the risk of editing working code again and again. A conditional nobody needs to extend carries no such risk; the principle is a reason to extract when branches keep arriving, not a ban on if.

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?

  1. Selection belongs where the request comes in, as data: a table from plan to a prepared strategy. Checkout receives one and calls pay, so a new plan is a new table entry and checkout never learns that plans exist.

  2. That puts a branch back into the context, the one place the pattern exists to keep free of them. Checkout would change for every new plan and would import both providers again.

  3. A strategy that knows its siblings and passes work to them is no longer interchangeable. Choosing what happens next from inside is the shape of the state pattern; a strategy should know its own job and nothing else.

  4. A subclass fixes the choice in a type instead of holding it in a reference, so every new variation needs another class, and plans times providers multiply fast. The choice is a value you pass in, not a class you write.

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?

  1. Then every order carries crypto settings and every strategy receives fields it ignores. The type stops describing what an order is, which is the signature that lies to every caller.

  2. Changing pay changes the interface every strategy implements, so the card and PayPal classes would have to accept values they never use. The interface should hold only what all strategies share.

  3. That teaches checkout one provider's needs, which is exactly the knowledge the pattern moved out of it. The next provider with its own settings would mean opening checkout again.

  4. Configuration is fixed when the strategy is built, so it belongs to the instance: new CryptoStrategy(apiKey, wallet). The interface stays pay(order), and only the class that needs the values ever sees them.

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?

  1. Same skeleton, opposite flow of control. A strategy is chosen from outside and knows nothing about its siblings; here each object picks its own successor, which a strategy never does.

  2. A plain status field with a switch in every method is the simpler alternative, and a fine one for a few stages. What is described here is more than a field: objects per stage that choose their successors.

  3. In the state pattern the object drives its own transitions: paid knows that shipping leads to shipped. With strategy, something outside makes every choice and the strategies stay unaware of one another.

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.