Skip to content

9.4.12 — Strategy

What the original Gang of Four book says: Define a family of algorithms, put each one into its own object, and make them interchangeable.

What that means when you are actually writing code: You have a switch statement that picks between several different ways of doing one single job. Give each of those ways its own object or its own function, and then let whoever is calling you choose which one they want.

Strategy is the easiest of all the patterns to learn, and it is also the one you will end up using most often. There is a second reason it deserves your attention first. Strategy is the cleanest possible demonstration of the first of the three moves we introduced back in 9.4.1, which is this: find the part of your code that keeps changing, and hide it behind a role that does not change.

It is worth learning this one properly rather than skimming it, because a surprising number of the other patterns turn out to be Strategy in a costume. State is Strategy where the object picks its own next strategy. Template Method is Strategy done through inheritance instead of composition. Chain of Responsibility is a list of strategies applied in order. Most systems that advertise themselves as having a "plugin architecture" are, underneath the marketing, a registry full of strategies. Once you can see the shape clearly here, you will start recognising it everywhere.

1. The story: the shipping calculator that grew a switch

The first version of the checkout shipped with exactly one carrier and exactly one rule, and the code that computed shipping was a single line long:

typescript
function shippingCost(order: Order): Money {
  return Money.of(5_00);                                   // a flat five dollars, every time
}

This is genuinely good code. It is honest, it is obvious, and anybody reading the checkout can see immediately what shipping costs. There is nothing to improve here and nothing to abstract, because there is only one behaviour and no sign of a second.

Then the business grew. Marketing wanted free shipping on orders over fifty dollars. Operations added a second carrier that charged by weight. The company started selling internationally, which brought in shipping zones. Somebody ran a promotional weekend with a special rate. Each of these arrived separately, months apart, and each one was added by whoever happened to be on call for that feature.

Eighteen months later, the function looks like the code below. Every engineer who reads this recognises it instantly, because every engineer has written it at some point:

typescript
function shippingCost(order: Order, method: string): Money {   
  if (method === "flat") {
    return Money.of(5_00);
  } else if (method === "free_over_50") {
    return order.subtotal.gte(Money.of(50_00)) ? Money.zero() : Money.of(5_00);
  } else if (method === "weight") {
    const kg = order.items.reduce((s, i) => s + i.weightKg * i.qty, 0);
    return Money.of(Math.ceil(kg) * 2_00 + 3_00);
  } else if (method === "international") {
    const zone = zoneFor(order.address.country);              // 40 more lines follow below
    const kg = order.items.reduce((s, i) => s + i.weightKg * i.qty, 0);
    const base = zone === 1 ? 12_00 : zone === 2 ? 22_00 : 35_00;
    return Money.of(base + Math.ceil(kg) * 4_00 + (order.address.remote ? 8_00 : 0));
  } else if (method === "promo_weekend") {
    // ... 15 lines. This promotion expired three days ago. Nobody will ever delete it.
  }
  throw new Error("unknown shipping method: " + method);      // ← fails at runtime, not compile time
}

Now, it is easy to look at that and say "well, that is ugly" and move on. That is not good enough, because "ugly" is a matter of taste and taste does not win design arguments. What follows is the precise list of things that have actually gone wrong. This list matters, because it is the entire justification for the pattern. If you can recite it, you can defend the refactor to a sceptical colleague or an interviewer.

Problem one: this single function now has five completely different reasons to change. Somebody in the international team wants to adjust the zone rate table. To do that, they have to open the same file, and edit inside the same function, that every single domestic order in the business also runs through. Their small change now requires re-testing behaviour they never intended to touch, and their pull request has to be reviewed by somebody who understands domestic shipping too. This is exactly what the Single Responsibility Principle warns about (9.3.5), and notice that the cost is not abstract. It is measured in review time, in test time, and in the risk of an unrelated production incident.

Problem two: adding a new carrier means editing code that already works. This is the Open/Closed Principle in its natural habitat. The ideal is that new behaviour arrives as new code that gets added, while the existing, tested, working code sits untouched. Here the opposite happens. Every single new requirement forces a modification to a function that is currently correct, and every modification is another opportunity to break something that used to work fine.

Problem three: all the branches share one scope, so they leak into each other. Look carefully and you will see that two different branches both compute kg from the order items. Sooner or later, a well-meaning engineer is going to notice that duplication and "helpfully" hoist that line up above the if statement so it is only written once. The moment they do, code that was written for international shipping starts executing for every flat-rate order as well. It will probably not break anything immediately, which is worse, because now there is a hidden dependency between two rules that were supposed to be completely unrelated.

Problem four: you cannot test one rule on its own. To test the weight-based calculation, you have to call shippingCost, which means you need the right magic string, and a fully constructed Order object with items and quantities and an address. So the tests are slow to write and slow to run. What actually happens in practice is that nobody writes the interesting edge cases. Nobody tests what happens with a zero-weight order, or an order weighing exactly four kilograms, or an empty cart, because setting up each of those costs more effort than the test seems worth.

Problem five: mistakes are found at runtime rather than at compile time. The method parameter is a plain string. If somebody writes "internatonal" with a missing letter, TypeScript is perfectly happy. The code compiles, the deploy goes out, and the error surfaces when a real customer is standing at a real checkout.

Problem six: the temporary promotional rate is now permanent. Deleting it would mean editing a shared function that handles every order in the business, and nobody wants to be the person whose cleanup commit broke checkout. So the branch stays. Then another temporary promotion gets added next quarter, and that one stays too. Dead branches accumulate forever, and each one makes the function harder to read for everybody who comes after.

Problem seven: you cannot add a rule without shipping a deploy. Suppose a large merchant negotiates a custom shipping rate as part of their contract. Or suppose the growth team wants to A/B test two different rate tables against each other to see which converts better. Neither of these is possible, because the complete list of available algorithms is welded into a source file. Anything that is not in that file simply cannot exist.

That is seven distinct problems, and every one of them traces back to the same root cause. Several genuinely different behaviours have been crammed into one function, where they are forced to share a name, a file, a scope, a test suite, and a deployment.

The switch statement is the smell that tells you this has happened. Here is what the cure looks like from the caller's point of view:

typescript
const cost = order.shipping.quote(order);   // ← the caller holds a strategy object, not a magic string

That single line has no branches in it, and it never will, no matter how many shipping rules the business invents.

2. How you arrive at the pattern

Patterns are much easier to remember when you understand how somebody would have discovered them, rather than memorising the finished shape. Here is the derivation, in the five steps we use throughout this chapter.

Step 1 — Start naive, and be happy about it. Write the algorithm inline, right where it is used, with no abstraction at all. That is what the original one-line version did, and it was the correct design for the situation it was in.

It is worth being emphatic about this, because pattern enthusiasm causes real damage. While there is genuinely only one way to do the job, turning it into a Strategy costs you an interface, an extra file, an extra layer of indirection, and a slightly harder debugging experience, and it buys you nothing whatsoever in return. The YAGNI principle from 9.3.4 beats pattern knowledge every single time. A junior engineer who leaves a one-line function alone has made a better decision than a senior engineer who wraps it in a strategy interface just in case.

Step 2 — Wait for the force that pushes you. The force here is specific. A second genuine way of doing the same job appears, and then usually a third. Critically, the choice between them has to be made while the program is running, based on data rather than on which build you deployed.

There is one more condition, and it is the one people skip. The alternatives have to be genuinely interchangeable. They take the same inputs, they return the same type, and most importantly they answer the same question. All five branches above answer "what does shipping cost for this order?" They differ only in how they work it out.

Step 3 — Draw the line between what varies and what stays fixed. This is the whole pattern, and it fits in two rows:

What variesthe algorithm — the actual method used to work out the cost
What stays fixedthe question being asked(Order) → Money, asked once during every checkout

Once you have drawn that line, the design writes itself. The thing that stays fixed gets written down exactly once, as an interface. Each thing that varies gets its own small class or its own function, and each of them implements that interface.

Step 4 — Decide when the choice gets made. For Strategy, the answer is at runtime, either once per object or once per call. The strategy might be selected when the order is created, or when the HTTP request arrives, or when configuration is loaded at startup, or the instant a user clicks a radio button.

That "chosen later, by somebody else, while the program is running" property is what separates Strategy from Template Method, which we cover in 9.4.17. Template Method makes its choice at compile time, by writing a subclass. If you can remember which one binds early and which binds late, you can always tell those two apart.

Step 5 — Name the pattern, and be equally clear about what it costs you. The name is Strategy. Being able to state the costs out loud is what separates somebody who understands a pattern from somebody who has merely memorised it.

The first cost is more types to keep track of. You now have five classes where you previously had one function. In a small codebase that is a genuine navigation burden, and pretending otherwise is dishonest.

The second cost is that the caller now has to know which strategies exist in order to pick one. That knowledge has to live somewhere. Usually you solve it with a factory or a registry, which is exactly why Strategy and Factory Method show up together so often in real codebases.

The third cost is harder debugging. When you look at the call site, it no longer tells you which code is about to run. You have to trace where the strategy came from, and in a large system with dependency injection that can take a few minutes.

The fourth cost is that one interface has to fit every member of the family. That sounds harmless until one of your algorithms needs an input that the others do not, at which point it becomes the hardest design problem on this page. Section 6 is entirely about that situation.

Against those four costs, here is what you get. Each algorithm becomes readable on its own, testable on its own, and replaceable on its own. Adding a new one touches zero lines of existing code. And behaviour that used to require a deploy can now be driven by configuration.

3. The mental model

In one sentence: a strategy is a plug-in answer to a fixed question, where the question is the interface and each answer is an object that can be swapped in without the asker ever noticing the difference.

The analogy that makes it stick — the maps app on your phone. "Get me from A to B" is the fixed question that never changes. Fastest route, shortest route, avoid tolls, walking, cycling and public transport are all interchangeable answers to it.

The app does not contain a giant if (mode === "walking") block. It holds a routing strategy and asks that strategy for a route. This is why the team can add "avoid highways" as a new option without touching a single line of the code that draws the map, and why the map-drawing code neither knows nor cares how many modes exist in total.

Notice the two properties that make this Strategy specifically, and not some other pattern. First, the caller is the one who picks the mode, and the routing engine never picks it for itself. Second, every mode answers the exact same question and returns the exact same kind of result.

When to reach for it. The trigger phrases are surprisingly consistent, and you will hear them in planning meetings long before you see them in code:

  • "depending on the type, or the mode, or the plan, or the tier, we need to calculate this differently"
  • "we need to support several payment providers" — or compression formats, or sort orders, or export formats
  • a switch or if / else if chain where every single branch does the same kind of work with the same inputs and the same output type
  • "make it pluggable", or "the customer should be able to configure this rule themselves"
  • an enum where each value quietly carries different behaviour along with it
  • "let's A/B test two versions of this calculation"

The sharpest test you can apply. Look hard at your switch statement and ask one question. Do all the branches take the same inputs and return the same type, differing only in how they get there?

If yes, those branches are strategies, and extracting them will help. If no — if one branch saves a file, another sends an email, and a third returns a number — then this is not Strategy at all. It is a dispatch table over unrelated operations, and forcing those into a single shared interface will make the code worse rather than better. Knowing when not to apply a pattern is at least as valuable as knowing when to apply it.

4. Structure

Checkout(the caller)the fixed questionShippingPolicyquote(order): MoneyFlatRateFreeOverThresholdWeightBasedInternationalZoneMerchantContract ④③ each answer implements the question② somebody outside picks one④ new rule = new file, zero edits
Figure 12 — One question, many interchangeable answers. ① The Checkout class (blue) knows only about the role, which is ShippingPolicy (purple). It never sees a concrete rule and never mentions one by name. ② The decision about which rule to use is made outside Checkout and handed in from there. ③ Every strategy (green) has the identical method signature, and that sameness is precisely what makes them swappable. ④ Adding the amber rule requires one new file and one new line in a registry. Not a single line of existing code is edited, which is what the Open/Closed Principle looks like when it actually works.

There are four participants in this pattern, and it helps to name them clearly because the same four names come up in every discussion of it.

The Strategy is the interface itself. It is the fixed question, written down once. In the diagram it is the purple box.

A ConcreteStrategy is one particular algorithm, one particular answer to that question. In the diagram those are the green and amber boxes, and there can be as many as you like.

The Context is the object that holds a strategy and calls it. In our example that is Checkout. A well-built context often does not even know the names of the concrete strategy classes, because it only ever refers to the interface.

The Client, sometimes called the composition root, is whatever picks the concrete strategy and hands it to the context. This might be a factory, a registry, a configuration loader, or your dependency injection container.

Now the rule that most implementations get wrong. The context must never be the thing that picks the strategy.

The moment Checkout contains a line like if (mode === "weight") this.policy = new WeightBased(), you have not removed the switch. You have simply moved it from one method to another, inside the same class, and you now have all the costs of the pattern with none of its benefits. The class still has to change every time a new rule is invented, which was the whole problem you were trying to solve.

The choice belongs at the edge of the system, in a factory, a registry, the composition root, or the code that reads configuration. That single rule is what separates a design that genuinely is open for extension from one that merely looks like it in a diagram.

5. The code, walked through line by line

typescript
export interface ShippingPolicy {                          // (1) the fixed question
  quote(order: Order): Money;                              //     one method, with an honest name
  readonly label: string;                                  //     data the user interface needs
}

export class FlatRate implements ShippingPolicy {          // (2) one algorithm, one file
  constructor(private readonly amount: Money) {}           // (3) takes a parameter, not hard-coded
  readonly label = "Standard";
  quote(_order: Order): Money { return this.amount; }
}

export class FreeOverThreshold implements ShippingPolicy {
  constructor(                                             // (4) a strategy can hold another strategy
    private readonly threshold: Money,
    private readonly fallback: ShippingPolicy,
  ) {}
  readonly label = "Standard (free over threshold)";
  quote(order: Order): Money {
    return order.subtotal.gte(this.threshold)
      ? Money.zero()
      : this.fallback.quote(order);                        //     ask the other one, do not copy it
  }
}

export class WeightBased implements ShippingPolicy {
  constructor(private readonly perKg: Money, private readonly base: Money) {}
  readonly label = "Weight-based";
  quote(order: Order): Money {
    const kg = order.items.reduce((s, i) => s + i.weightKg * i.qty, 0);   // (5) local, not shared
    return this.base.plus(this.perKg.times(Math.ceil(kg)));
  }
}

export class Checkout {                                    // (6) the context
  constructor(private readonly policy: ShippingPolicy) {}  // (7) handed in — no switch in here
  total(order: Order): Money {
    return order.subtotal.plus(this.policy.quote(order)).plus(this.tax(order));
  }
}

Now let us go through the numbered lines one at a time, because each of them encodes a decision that is easy to get wrong.

(1) The interface is the fixed question, and naming it well is most of the design work.

The name ShippingPolicy describes a role in the business. A person who has never seen this codebase can guess what it does. Compare that with the names beginners tend to reach for. IShippingCalculatorStrategy names the pattern rather than the concept, which tells a future reader nothing useful about the business. ShippingHelper names nothing at all, and a class called "helper" is almost always a bag of unrelated functions waiting to happen.

There is a second thing to notice on this interface, and it is easy to miss. Alongside the quote method, there is a label property. Strategies very often need to expose facts about themselves, not just perform a calculation. A display name for a dropdown, a code for logging, a check for whether this option is even available to this customer. Putting those facts on the interface is what lets the user interface list the available options without a second switch statement hiding somewhere in the presentation layer. If you forget this, the branching you removed from the calculation tends to reappear in the view code a few weeks later.

(2) One algorithm per class, and one class per file.

The benefit here is stronger than it first appears. It is not that the code is tidier. It is that a change to weight-based pricing now physically cannot break flat-rate pricing. Not by convention, not by discipline, not because everybody promised to be careful. The two pieces of code are in different files, they run in different test files, and they appear in different pull requests. The isolation is structural rather than social, which means it survives staff turnover.

(3) Strategies take constructor parameters.

This is the difference between a strategy and a hard-coded lump. new FlatRate(Money.of(5_00)) and new FlatRate(Money.of(7_50)) are the same algorithm running at two different settings, so you do not need a separate class for each price point.

The rule of thumb that keeps this straight: differences in behaviour become different classes, while differences in value become constructor parameters. If two proposed classes would have identical method bodies except for a number, you want one class and a parameter.

(4) Strategies can hold other strategies.

Look at what FreeOverThreshold does. Instead of duplicating the flat-rate maths for the case where the order is below the threshold, it holds another ShippingPolicy and asks it. This is the same wrapping idea you saw in Decorator, applied here to interchangeable algorithms rather than to a wrapped component.

This composability is quietly the biggest practical win of the whole pattern, and it is the part tutorials tend to skip. With five simple strategies plus the ability to wrap them, you can express far more combinations than five branches ever could, and you can express combinations nobody thought of when the code was written. "Free over fifty dollars, otherwise weight-based" requires no new code at all. It is new FreeOverThreshold(Money.of(50_00), new WeightBased(...)), assembled at configuration time.

(5) Each algorithm keeps its own intermediate values.

The variable kg exists only inside the one algorithm that needs it. Compare this with the switch version, where kg was computed inside two different branches that shared a scope, sitting there waiting for somebody to hoist it upward and accidentally couple two unrelated rules. Here that mistake is not available. There is no shared scope to hoist into.

(6) The context calls the strategy and does nothing else about shipping.

Checkout knows that shipping gets quoted. It does not know how, and it does not know how many ways there are. That is the entire amount of coupling it should spend on this concern, and keeping it that small is what makes Checkout stable while the shipping rules churn.

(7) The strategy is handed in, never chosen inside.

This is the line that everything else depends on, and it is worth stating twice because it is the one people break. Any if statement inside Checkout that selects a policy rebuilds the original problem in a new location. If you find one during code review, that is the comment to leave.

What this actually does when you run it:

typescript
const domestic = new Checkout(new FreeOverThreshold(Money.of(50_00), new FlatRate(Money.of(5_00))));
domestic.total(order);            // subtotal 60.00 → shipping 0.00 → total 60.00 plus tax
const heavy = new Checkout(new WeightBased(Money.of(2_00), Money.of(3_00)));
heavy.total(order);               // 4.2 kg → rounded up to 5 → 3.00 + (5 × 2.00) = 13.00 shipping

5.1 The function version, and when it is the better choice

In any language that has first-class functions, an interface with exactly one method is really just a function type wearing a suit. A good half of the Strategy implementations you write in TypeScript should look like this instead:

typescript
type ShippingPolicy = (order: Order) => Money;             // ← the interface, as a type alias

const flatRate = (amount: Money): ShippingPolicy => () => amount;              // makes a strategy
const weightBased = (perKg: Money, base: Money): ShippingPolicy => (order) =>
  base.plus(perKg.times(Math.ceil(order.items.reduce((s, i) => s + i.weightKg * i.qty, 0))));
const freeOver = (threshold: Money, fallback: ShippingPolicy): ShippingPolicy => (order) =>
  order.subtotal.gte(threshold) ? Money.zero() : fallback(order);

const total = (order: Order, policy: ShippingPolicy) => order.subtotal.plus(policy(order));

Everything that the constructor used to hold is now held by the closure instead. The outer function, the one that takes perKg and base and returns a function, is the parameterised strategy from note 3 above. The behaviour is identical, and there is considerably less ceremony.

So which should you use? Reach for the function form when the strategy is a single operation that carries no extra data and needs no metadata. Reach for the class form when the strategy needs several methods, or a label and other information about itself, or an identity that can be written to a database and read back later, or when your team's tooling expects classes because you use a dependency injection container or decorators.

Both of these are the same pattern. Anybody who tells you that Strategy requires classes is describing Java rather than describing the idea. This is the clearest possible illustration of the point made in 9.4.1: a pattern is a shape, not a class diagram, and the same shape can be expressed in whatever your language makes convenient.

5.2 Python: Protocol gives you duck typing that the type checker understands

python
from typing import Protocol
from decimal import Decimal

class ShippingPolicy(Protocol):                    # structural: no inheritance required
    def quote(self, order: "Order") -> Decimal: ...

class FlatRate:                                    # note: does NOT subclass ShippingPolicy
    def __init__(self, amount: Decimal) -> None: self.amount = amount
    def quote(self, order): return self.amount

class WeightBased:
    def __init__(self, per_kg: Decimal, base: Decimal) -> None:
        self.per_kg, self.base = per_kg, base
    def quote(self, order):
        kg = sum(i.weight_kg * i.qty for i in order.items)
        return self.base + self.per_kg * Decimal(math.ceil(kg))

def checkout_total(order, policy: ShippingPolicy) -> Decimal:
    return order.subtotal + policy.quote(order)    # duck typing, but checked by mypy

A Protocol in Python means "any class that happens to have these methods counts as this type". You get the safety of an interface without forcing anybody to inherit from a base class.

That distinction has a practical payoff worth understanding. Suppose a third-party library gives you a class that already has a quote(order) method with the right shape. With a Protocol, that class satisfies ShippingPolicy immediately, with no adapter and no wrapper. With a traditional abstract base class, you would have to write a wrapper purely to satisfy the type system.

In plain everyday Python, a bare function or a functools.partial is even more idiomatic than either. The Protocol starts earning its keep when the strategy has several methods, or when you want mypy to catch the case where somebody writes a new strategy and forgets one of them.

6. The hard part: when one algorithm needs input the others do not

Every real-world Strategy eventually runs into the same wall, and it is worth spending real time on it because the way you handle it is what separates a design that ages well from one that rots.

The wall is this. One of your algorithms needs an input that none of the others need.

The international rule needs to know the destination country and needs access to a zone table. The merchant-contract rule needs a merchant ID and a database connection to look up the negotiated rate. The promotional rule needs to know what time it is, so it can tell whether the promotion is still running.

The tempting move is to widen the shared method every time this happens. Add country, then add merchantId, then add clock, then add db. After four rounds of that you have quote(order, country, merchantId, clock, db), and every strategy in the family now accepts five parameters of which it ignores four. That is a direct violation of the Interface Segregation Principle from 9.3.8, and it also means each implementation's signature is actively lying about what that implementation uses. A reader cannot tell from the signature which inputs matter.

There are four honest ways out, and choosing correctly between them is a genuine design skill rather than a lookup:

The fixWhat it looks like in codeWhen it is the right one
Pass one context objectquote(ctx: QuoteContext) where the context holds order, address, clock and merchanttwo or more strategies need extra data, and that data naturally belongs together
Give the strategy its own dependencynew InternationalZone(zoneTable, clock) — only that one class holds itthe extra input is a collaborator, not something that changes per call
Put the data on the domain objectadd destination to Order, where it probably belonged in the first placethe missing input is genuinely part of the concept you are modelling
Accept that there are two rolesShippingPolicy and InternationalShippingPolicy are simply different thingsthese so-called algorithms are not actually interchangeable at all

The second option is the default, and it is badly under-used. The rule of thumb is short enough to memorise: data that changes per call goes in the method; anything the strategy needs every single time goes in the constructor.

A zone table is not per-call data. It is a thing that one particular algorithm always needs, on every call, forever. So it belongs in that class's constructor, and the shared method signature stays exactly as narrow as the question being asked:

typescript
export class InternationalZone implements ShippingPolicy {
  constructor(                                       // collaborators live here …
    private readonly zones: ZoneTable,
    private readonly surcharge: RemoteAreaSurcharge,
  ) {}
  readonly label = "International";
  quote(order: Order): Money {                       // … so the signature stays the question
    const zone = this.zones.lookup(order.address.country);
    const kg = order.items.reduce((s, i) => s + i.weightKg * i.qty, 0);
    return zone.base.plus(zone.perKg.times(Math.ceil(kg))).plus(this.surcharge.for(order.address));
  }
}

Notice what this achieves. InternationalZone has everything it needs, FlatRate is not forced to accept a zone table it will never look at, and the interface that binds them together stays a clean single-parameter method.

The fourth option in that table is the one junior engineers almost never consider, and it deserves real explanation.

If you keep trying to widen the interface and it keeps feeling wrong, the honest conclusion might be that these are not interchangeable algorithms in the first place. Two things that answer genuinely different questions should not be forced to share an interface merely because both of them sound shipping-related.

It helps to think of a shared interface as a promise being made to every caller. The promise is: any implementation of this interface can be used anywhere the role is expected, and the caller does not need to know which one it got. If you force that promise onto implementations that cannot honour it, you end up with strategies that throw NotSupportedException for certain inputs, or with callers that have to check which concrete type they received before using it. Both of those are violations of the Liskov Substitution Principle, and both make the code worse than the original switch statement did.

So splitting one role into two is not the pattern failing you. It is the pattern doing its job, which is telling you something true about your domain that you had not noticed yet.

6.1 Selecting the strategy: use a map, not another switch

Extracting the strategies removed the switch from the algorithm, but something in the system still has to turn an incoming request into a concrete policy object. That mapping does not disappear. What you can do is confine it to one place, express it as data, and put it at the edge of the system:

typescript
const POLICIES: Record<ShippingMethod, () => ShippingPolicy> = {   // (1) a map, not a switch
  standard:      () => new FreeOverThreshold(Money.of(50_00), new FlatRate(Money.of(5_00))),
  weight:        () => new WeightBased(Money.of(2_00), Money.of(3_00)),
  international: () => new InternationalZone(zoneTable, surcharge),
};

export function policyFor(method: ShippingMethod): ShippingPolicy {
  const make = POLICIES[method];
  if (!make) throw new UnknownShippingMethod(method);              // (2) exactly one place that fails
  return make();
}

(1) Here ShippingMethod is a union type, meaning it can only be "standard", "weight" or "international". Because the map is declared as Record<ShippingMethod, …>, the TypeScript compiler now forces every member of that union to have an entry. Add a fourth shipping method to the union and the build fails immediately, and it keeps failing until you register a policy for it.

Stop and appreciate what just happened, because it is genuinely better than what the original code had. The switch version could silently miss a case and fail at runtime in front of a customer. This version cannot compile with a case missing. You have converted a production incident into a build error, which is the best trade available in software. This union-plus-Record combination is the standard TypeScript spelling for a strategy registry, and it builds on the narrowing rules covered in 3.7.3.

(2) The "unknown method" failure now lives in exactly one small function rather than at the bottom of a long algorithm. That makes it easy to test, easy to log usefully, and easy to turn into a clean HTTP 400 response instead of a confusing 500.

If you need a plugin system where strategies are not even known at compile time, the same idea becomes a mutable registry with a register(name, factory) method. That is precisely how Express decides which body parser to use, how Webpack decides which loader handles a file, and how your logging library decides where to send output.

7. Where you would actually use this

(a) Prices, discounts, taxes and fees. Every commerce system has interchangeable rules for shipping, tax, discounts and commission, and these are the rules most likely to change every week.

They differ per market because tax law differs per market. They get A/B tested because the business wants to know which converts better. They get configured per merchant because large merchants negotiate. Every one of those requirements is impossible with a switch statement and straightforward with an injected policy object. If an interviewer asks you to design a pricing engine, the expected opening move is to say that each pricing rule becomes a strategy behind a price(order) → Money interface.

(b) Sorting, which is Strategy hiding inside your standard library. The call array.sort(comparator) is Strategy. The sorting algorithm itself is fixed and lives in the runtime. The comparator is the pluggable decision that you supply.

The same shape appears as Comparator in Java, as the predicate argument to std::sort in C++, as the key= parameter in Python, and as the ORDER BY clause in SQL. Recognising this teaches you how far the pattern reaches. You have used Strategy thousands of times already without ever calling it that.

(c) Login and permission checks. The Passport library calls its plugins "strategies", and it is named after this pattern deliberately. Local passwords, OAuth, SAML, JWT and API keys all answer one single question — who sent this request? — and differ only in how they work it out. The framework owns the role, and you register the answers you need.

(d) Retry and rate-limiting policies. A RetryPolicy might be fixed delay, exponential backoff, exponential backoff with jitter, or no retry at all. A RateLimitPolicy might be a token bucket, a sliding window or a fixed window, all of which are covered in 9.7.5.

This is the form that turns up most often in system design interviews, because "which backoff strategy?" is a decision you want to make separately for each dependency rather than compiling one choice into the whole system (10.9).

(e) Serialisation, compression and export formats. JsonSerializer, ProtobufSerializer and CsvExporter all sit behind one Serializer role. Gzip, Brotli and Zstandard all sit behind one Compressor role.

There is something worth noticing in this example. The choice arrives at runtime from an HTTP header, either Accept or Accept-Encoding. In other words, the client is naming the strategy over the network, and your registry maps that name onto an object. That is about as literal a demonstration of runtime strategy selection as you will find.

(f) Feature flags and experiments. A flag that switches between two implementations of the same operation is a Strategy whose selection function reads from a flag service.

Consider what happens when the experiment ends. Written as a switch, the losing branch stays in the code forever because deleting it means editing shared logic. Written as strategies, retiring the experiment means deleting one file and one line in a registry, which anybody can do safely on a Friday afternoon.

(g) Test doubles. Injecting a FakeShippingPolicy that always returns a constant is Strategy used purely to make testing easy. This is a large part of why a Strategy-shaped design is so much cheaper to exercise in isolation than a branchy one: you can substitute one behaviour without constructing the world around it.

8. Variants

VariantWhat it looks likeNotes
Classic object strategyan interface plus concrete classeswhen the strategy needs data or several methods
Function strategya function type plus closuresthe default in JavaScript, TypeScript and Python for single-method roles
Parameterised strategyconstructor arguments tune one algorithmdifferences in value are not differences in behaviour
Wrapped strategyone strategy holds and calls anotherFreeOverThreshold(…, FlatRate); very close to Decorator
Null strategya do-nothing member such as NoDiscount or NoRetryremoves if (policy) checks everywhere — the Null Object idea
Registry or plugin strategya Map<name, factory> that is open to registrationthird-party or configuration-driven strategies
Chosen per callpassed as an argument rather than storedsort(comparator); the context stays stateless
Default plus overridethe context has a sensible default strategykeeps the simple case simple for callers

The Null strategy deserves its own paragraph, because it removes an entire category of noise from a codebase and most teams never think of it.

Suppose "no discount" is represented by null. Every single call site now needs if (discount) { … } before it can do anything, and every one of those checks is a place where somebody can forget. Now suppose "no discount" is instead an object called NoDiscount whose apply method returns its input unchanged. Every call site becomes unconditional, and the concept of "there is no discount here" is expressed once, in one small class, rather than repeated as a null check in twenty places.

That is the Null Object pattern, and it is the cheapest and highest-value companion to Strategy that exists. The same idea gives you NoRetry, NoopLogger and NullCache.

9. Where you already use it

What you have usedThe one jobThe swappable answers
items.sort(compareFn)how to compare two itemsany function you pass
items.filter(fn), items.map(fn)keep which, turn into whatany function you pass
A Hasher interface over a password libraryscramble a passwordwhichever algorithm security asks for
A login step you can point at different providersprove who the user ispassword, one-time code, company sign-on
Sorting a table by whichever column was clickedpick the orderingone comparison function per column

The first row is the one you have used a thousand times without noticing. sort knows how to arrange a list. It does not know what "in order" means for your data — smallest number first, newest date first, or names in dictionary order. So it does not decide. It takes the comparison as an argument and calls it whenever it needs to know which of two items comes first.

That is the entire pattern in one built-in method. The part that stays the same is the sorting itself, and the whole of JavaScript shares one copy of it. The part that changes is the comparison, and you supply that at the call site. Had sort tried to guess instead, it would need a giant switch over every kind of data anyone might ever sort, and you would be unable to sort anything it had not heard of.

10. Ways to get it wrong

  1. The switch simply moved into the context. You see if (mode === …) this.policy = … inside the very class that uses the policy.

    The fix: make the choice at the edge of the system, in a factory, a registry, or the composition root, as shown in section 6.1.

  2. A Strategy built for a single algorithm. One interface with exactly one implementation, added "for flexibility later". This is speculative generality, and YAGNI (9.3.9) says to inline it. Extract it when the second algorithm genuinely arrives. The refactor takes about fifteen minutes, and by then you will be doing it with real requirements in front of you rather than imagined ones.

  3. A separate class for every parameter value. FiveDollarShipping and SevenDollarShipping as different classes.

    The fix: one class with a constructor parameter, as explained in note 3 of section 5.

  4. The bloated interface. quote(order, country, merchant, clock, db, flags) because each strategy needed one more thing.

    The fix: the four options in section 6, and the default answer is usually to give that strategy its own dependency.

  5. Strategies that keep state and then get shared. A strategy that stores data between calls is not safe to reuse, and two orders processed at the same time will corrupt each other's results. This is covered in more depth in 9.5.4.

    The fix: keep strategies stateless and immutable, or create a fresh one per use.

  6. The context checking the concrete type. A line like if (this.policy instanceof InternationalZone) anywhere inside the context destroys the entire benefit, because the context now depends on a specific implementation again.

    The fix: if the context genuinely needs to know something, the interface is missing a method. Perhaps it needs requiresCustomsForm(): boolean.

  7. Strategies that are not actually interchangeable. One of them throws NotSupported, or callers have to check which one they were given.

    The fix: split the role in two, as described in row four of section 6.

  8. Naming the interface after the pattern. IShippingStrategy.

    The fix: name the role in the business, so ShippingPolicy. Patterns are vocabulary for discussing code with other engineers, not for naming things inside it (9.4.1).

11. Strategy compared with its neighbours

Compared withThe differenceChoose Strategy when
Statethe structure is identical, but State objects decide what comes next and change the object's behaviour over its lifetime. Strategies are independent of each other and are chosen from outsidethe choice is made once, from outside, and then stays
Template MethodTemplate Method varies individual steps using inheritance, fixed at compile time. Strategy swaps whole algorithms using composition, at runtimeyou need runtime swapping, or you want to avoid inheritance
CommandCommand packages a request so it can be queued, logged or undone. Strategy packages how to do one step of an ongoing jobthe object answers a question rather than representing an action
DecoratorDecorator wraps existing behaviour and adds to it, keeping the same interface. Strategy replaces the behaviour entirelyyou want a different implementation, not an extended one
Bridge (9.4.1 section 2)pairs up two whole families of classesonly one behaviour varies, not two families
Factory Methodnot an alternative at all. The factory is usually what creates the strategyuse both together — the factory selects, the strategy performs

Strategy versus State is the interview's favourite trap, and it is worth being able to answer precisely rather than vaguely.

The reason it is a trap is that the class diagrams are genuinely indistinguishable. Both show a context holding an interface, with several classes implementing that interface. If you try to tell them apart by looking at structure, you will fail, because there is no structural difference to find.

The difference is intent, and specifically it is about who drives the change.

In Strategy, the client picks an algorithm, and that choice normally stays put for the whole operation. The strategies know nothing about each other, because WeightBased has genuinely never heard of FlatRate and has no reason to. Swapping one for another is a configuration decision made by a human or a config file.

In State, the object's behaviour changes as it moves through a lifecycle. The states know the transition rules, and they typically hand the object its next state directly. Swapping happens as a consequence of an event arriving, not as a configuration choice.

Here is the sentence to give in an interview: "Same structure, opposite driver. A strategy is chosen from outside and then stays. A state changes itself from the inside as events arrive." The full treatment is in 9.4.14.

12. Interview calibration

The 45-second answer, in the order you would say it:

Strategy takes a family of interchangeable algorithms and turns each one into an object behind a single interface, so the caller chooses which one to use at runtime. The trigger I look for is a switch where every branch does the same job in a different way, like shipping cost by method. Each branch becomes a class or a function implementing something like quote(order): Money.

The context holds the interface and calls it, and something at the edge of the system — usually a registry keyed by a union type — picks the concrete one. What I get is that a new rule becomes a new file instead of an edit to working code, each algorithm gets its own focused unit tests, and rules become configurable per customer or A/B testable.

What it costs is more types to navigate and one extra hop when debugging, so I do not reach for it until the second algorithm actually exists. In TypeScript I will often use a function type rather than an interface, which is the same pattern with less ceremony.

Follow-up questions, with the seed of each answer:

  • "Strategy versus State?" — Same structure, opposite driver. A strategy is picked from outside and stays put. A state changes itself as events arrive, and the states know their own transitions.
  • "Strategy versus Template Method?" — Composition at runtime versus inheritance at compile time. Strategy swaps whole algorithms; Template Method fills in individual steps of a fixed skeleton.
  • "Where does the selection logic live?" — At the edge of the system, expressed as a Record<Union, factory> map. That way the compiler forces every case to be handled, and the context stays free of branches.
  • "What if one strategy needs extra input?" — Give that strategy its own dependency in its constructor, or pass a single cohesive context object. If neither fits, they are probably not interchangeable, and the honest answer is to split the role.
  • "Isn't this over-engineering?" — For a single algorithm, yes, absolutely. The second algorithm is the trigger. Before that, inlining it is the better design.
  • "Do strategies have to be classes?" — No. An interface with one method is just a function type, and a closure carries exactly what a constructor would have carried.

Recall

  • Strategy means a family of interchangeable algorithms sitting behind one interface, with the caller choosing which one at runtime. The trigger to look for is a switch where every branch does the same job with the same inputs and the same output type, and only the method of getting there differs.
  • How you arrive at it: what varies is the algorithm, and what stays fixed is the question being asked, which here is (Order) → Money. Write the fixed question down once as an interface, then write one small class or function for each answer. The choice is made at runtime, either per object or per call.
  • The rule everything depends on: the context must never pick its own strategy. A switch inside the context has simply moved the problem to a new location. Make the choice at the edge with a Record<UnionType, factory> map, which has the bonus that the compiler then forces you to handle every case.
  • Getting the details right: name the interface after the business role, so ShippingPolicy and never IShippingStrategy. Differences in value become constructor parameters, while differences in behaviour become new classes. Keep strategies stateless so they are safe to share between concurrent requests. Let strategies wrap and call each other instead of duplicating logic. Add a Null strategy such as NoDiscount to delete null checks from every call site. Use a plain function type when the role has only one method.
  • When one algorithm needs extra input, there are exactly four honest fixes: give that strategy its own dependency in its constructor (the usual answer), pass one cohesive context object, put the data on the domain object where it probably belonged, or admit that these are not interchangeable and split the role in two.
  • Neighbours: State has the same structure with the opposite driver. Template Method does the same job through inheritance at compile time. Decorator adds to behaviour rather than replacing it. Factory creates the strategy, so the two are partners rather than alternatives.
  • Common mistakes: building it for one implementation "just in case", writing a class per price point instead of using a parameter, letting the interface bloat to satisfy one member, sharing strategies that hold state, and using instanceof checks inside the context.

Self-test: Which shape of switch means Strategy, and which shape definitely does not? Why must the context never pick the strategy itself? Name the four fixes for when one algorithm needs extra input. Give the one-sentence answer for Strategy versus State. When is the function form the better choice than the class form?

Quiz Bank

FoundationalShow how Strategy is derived from a branching shipping calculator, and list precisely what the switch version costs you.

The naive starting point is a single inline algorithm. That is correct code, and it is genuinely preferable, for as long as there is only one way to do the job. Turning it into a Strategy at this stage would add an interface, a file and a layer of indirection while buying nothing at all.

The force that changes things is the arrival of a second, and then a third, interchangeable way of doing the same job, where the choice between them has to be made while the program is running.

What the switch version costs, item by item. First, the function acquires five separate reasons to change, so an adjustment to the international rate table forces you to touch, retest and re-review the code path that every domestic order also travels through. Second, the Open/Closed Principle is broken, because a new carrier requires modifying working code rather than adding new code, and every modification carries regression risk. Third, all the branches share one scope, so when somebody eventually hoists a shared calculation above the if statement, two rules that were meant to be unrelated become silently coupled. Fourth, no rule can be tested in isolation, because every test has to run through the whole function with a magic string and a fully built order, which is why the interesting edge cases never get written.

Fifth, mistakes surface at runtime instead of at compile time, since the selector is a plain string and a typo will happily deploy. Sixth, dead branches accumulate permanently, because deleting a finished promotion means editing shared code that everybody is afraid to touch. Seventh, there is no runtime extensibility whatsoever, so per-merchant contract rates, A/B tests and configuration-driven rules are simply impossible, because the complete list of algorithms is welded into a source file.

Drawing the line: the algorithm varies, while the question — (Order) → Money — stays fixed.

When the choice is made: at runtime, either handed to the context when it is constructed or passed in per call.

The resulting pattern is ShippingPolicy as the role, one small class or function per rule, a context that holds the role and calls it, and a map at the edge of the system that turns an incoming request into a concrete policy.

What the pattern costs in return: more types to navigate, one more hop when tracing a bug, the caller needing to know that strategies exist at all (which the factory solves), and a single interface that has to fit every member of the family.

When to actually do it: at the second algorithm. Not at the first, and not at the imagined third.

FoundationalStrategy and State have the same class diagram. Give the exact difference, an example of each, and explain how you would tell them apart in code you have never seen before.

They really are structurally identical. In both cases a context holds a reference to an interface, and several classes implement that interface. If you try to distinguish them by looking at the shape of the code, you will get nowhere, because there is no shape difference to find. The difference lives in the intent, and specifically in who drives the change.

Strategy. The implementations are interchangeable answers to one fixed question. Something outside the object — the client, a configuration file, a feature flag — picks one, and that choice normally stays for the whole operation. The strategies are mutually unaware, in the sense that WeightBased has genuinely never heard of FlatRate and has no reason to. Swapping one for another is a configuration decision. The everyday example is the shipping policy chosen at checkout because the merchant configured it that way.

State. The implementations are modes in the lifecycle of one object. The object moves between them by itself, in response to events arriving. Because somebody has to encode the rule "after this comes that", the states almost always reference each other or share a transition table. Swapping happens as a consequence of something happening, not as a configuration choice. The everyday example is an order moving from Placed to Paid to Shipped, which is worked through fully in 9.7.3.

Three practical tells for unfamiliar code. The first is to ask whether the field is ever reassigned during the object's life. If it is set once in the constructor and never touched again, you are looking at Strategy. If you find this.state = next inside the implementations, or immediately after an event is handled, you are looking at State. The second is to ask whether the implementations know about each other. Strategies do not, while states almost always do, either directly or through a shared transition table. The third is to ask whether this set of classes represents alternatives or phases. "Fastest, shortest, avoid tolls" are alternatives, which makes them strategies. "Idle, Dispensing, OutOfStock" are phases, which makes them states.

The sentence for an interview: "Same structure, opposite driver. A strategy is chosen from outside and then stays. A state changes itself from the inside as events arrive."

The follow-on point that impresses. Because the two are structurally identical, you can quite reasonably start with Strategy and let it grow into State once transition rules appear. The refactor is small: you add a return value so each implementation can hand back the next one. That is a much better story than claiming you should have predicted the lifecycle up front.

AppliedYou applied Strategy, but one algorithm needs input the others do not — the international rule needs a zone table and the destination country. Walk through every option and pick a default.

This situation arrives in every real Strategy implementation, so the way you handle it matters more than the initial extraction did.

The tempting wrong move is to widen the shared method every time a new need appears, until you end up with quote(order, country, merchant, clock, db). Every strategy in the family then accepts five parameters and ignores four of them. That breaks the Interface Segregation Principle, and it also means each implementation's signature is lying to the reader about which inputs actually matter to it.

There are four honest fixes.

The first is to give the strategy its own dependency, so you write new InternationalZone(zoneTable, surcharge). The reasoning is that the zone table is not per-call data at all. It is something that one particular algorithm needs on every single call, forever, which makes it a collaborator rather than a parameter. Collaborators belong in the constructor, and the effect is that the shared method signature stays exactly as narrow as the question being asked.

The second is to pass one cohesive context object, so quote(ctx: QuoteContext) where the context bundles the order, the address, the clock and the merchant. This is the right choice when several strategies need extra data and that data genuinely belongs together as a concept. It becomes the wrong choice the moment the context turns into a grab-bag of unrelated fields, because at that point it is just the bloated interface again with a nicer name.

The third is to put the data on the domain object. If the destination address is genuinely part of what an Order is, then put it there. Quite often the "missing input" is really a modelling gap, and fixing the model is better than working around it in every strategy.

The fourth is to accept that you have two roles rather than one. If widening keeps feeling wrong, these may not be interchangeable algorithms. It helps to think of a shared interface as a promise to callers: any implementation works wherever this role is expected. Force that promise onto implementations that cannot honour it and you get strategies throwing NotSupported, or callers checking which concrete type they received. Both are Liskov violations, and both are worse than the original switch.

The default is the first option, and it is badly under-used in practice. The rule of thumb is short: data that changes per call goes in the method, and anything the strategy always needs goes in the constructor.

The decision procedure, in order. Does the extra input change from call to call? If not, use constructor injection and stop. If it does change per call, ask whether several strategies need it. If several do and the data hangs together as a concept, use a context object. If the data really belongs to the domain concept, put it on the domain object. And if only one strategy will ever need it and it cannot be injected, you should seriously suspect you are looking at two different roles.

InterviewDesign a discount engine where marketing can add new promotions without a code deploy, and where rules stack. Use Strategy, and be honest about what it does not solve.

The basic shape. Each promotion becomes a strategy behind an interface like Discount { applies(cart, ctx): boolean; apply(cart): Adjustment; readonly id: string; readonly priority: number }.

Notice that the interface carries the applicability check and the metadata, not just the calculation. That is deliberate, because a discount engine has to decide whether a promotion applies as well as how much it is worth, and if the applicability logic lives outside the strategy then you have split one concept across two places. The concrete strategies are things like PercentOff, AmountOff, BuyXGetY, FreeShipping, TieredVolume and BundlePrice.

Stacking is the real design work, and it is a second pattern rather than part of Strategy. A DiscountEngine takes the list of applicable strategies and applies them according to a stacking policy, and that policy is itself a strategy. BestSingle applies only the largest discount. Sequential applies them in priority order, each one working on the running total. AdditiveOnSubtotal computes them all against the original subtotal and then sums them.

Making the stacking rule pluggable matters for two reasons. Merchants genuinely disagree about which behaviour they want, and more importantly "20% off then $10 off" does not produce the same total as "$10 off then 20% off". That ordering ambiguity has to be a deliberate decision that somebody wrote down, not an accident of what order the rows came back from the database. The engine also enforces hard limits that no individual strategy can override: a floor at zero, a maximum total discount, and exclusivity groups where a promotion marked exclusive cannot combine with anything else.

Authoring without a deploy, at two levels of ambition. The first level is parameterised strategies loaded from data, so a row looks like {"type":"percent_off","value":20,"conditions":[...]} and a registry factory turns it into an object with POLICIES[row.type](row.params). Marketing combines existing types with new values and conditions. There is no deploy, the type safety is intact, and a badly configured row breaks one promotion rather than the application. This covers roughly ninety-five percent of real promotions and should be your default answer. The second level is a small expression language for the conditions, using something like CEL or JSONLogic, evaluated in a sandbox with a time limit and a memory limit. It is more expressive and materially more risky, and it needs versioning, a preview mode and an audit trail before you can let anyone near it.

What Strategy does not solve, which is where a good answer becomes a great one. It does nothing about whether the authored rule is correct, so a wrong percentage still deploys instantly to production, which means you need a dry-run simulator that replays recent orders and a staged rollout. It does nothing about combinatorial explosion, because with forty active promotions the bugs come from interactions rather than individual rules, so you need property-based tests over generated carts asserting that the total never drops below zero, never exceeds the cap and never applies two exclusive promotions. It does nothing about performance, since evaluating every promotion for every cart is linear in the number of promotions, so you need to index promotions by what they apply to and only evaluate the plausible candidates. And it does nothing about auditability, because finance will eventually ask why a specific order was discounted, which means apply must return a structured Adjustment { discountId, version, amount, reason } and the order must persist the whole trace rather than just the final number.

The summary sentence: each promotion is a strategy loaded from data by a registry, stacking is a separate pluggable policy with explicit ordering and hard caps, and the genuinely interesting engineering is in simulation, indexing and an auditable adjustment trace, because Strategy makes new rules cheap to add but does nothing to make them correct.

StaffA payment platform hard-codes provider logic in a 3,000-line PaymentService with nested conditions on provider, country, currency and card type. Chargebacks are rising because retry and 3-D Secure behaviour drifted between branches. Plan the refactor.

The diagnosis comes first, and it is not "this function is too long". Three genuinely different kinds of variation have been collapsed into one branching structure. As a result, behaviour that should have been shared across the whole platform — retry logic, 3-D Secure handling, idempotency — was reimplemented inside each branch and then drifted apart over time. That drift is the direct cause of the chargebacks, and recognising it is the insight the question is testing.

Separate the dimensions before you write a single class. The first dimension is the provider (Stripe, Adyen, a local acquirer), which is a Strategy behind a PaymentProvider role. The second is cross-cutting policy (retry, timeout, 3-D Secure step-up, fraud check, logging), which is not per-provider behaviour at all and belongs in Decorator layers that apply uniformly. The third is routing, meaning which provider handles this country, currency and card type, which is a separate decision belonging in its own routing strategy. The fourth is capabilities, meaning whether a given provider supports partial capture, recurring payments or 3-D Secure version 2, which is data the router reads rather than conditions buried in the algorithm.

The target design. Every provider becomes an Adapter behind one PaymentProvider port exposing authorize, capture, refund and void. Each adapter translates requests, responses and — most importantly — error meanings. The error translation matters most here because "this failure is worth retrying" versus "this failure is final" is exactly the distinction a chargeback-safe retry depends on, and every payment SDK expresses it differently. A PaymentRouter strategy then chooses the provider based on country, currency, card type and amount, checking capabilities first. Cross-cutting behaviour becomes decorators applied once at the composition root, written as withIdempotency(withRetry(withMetrics(withFraudCheck(provider)))), and the ordering is a deliberate documented decision. Idempotency must sit outside retry, because otherwise a retry creates a second charge, and that is very likely one of the sources of the chargebacks you were asked to fix (9.4.8 section 6 covers the ordering rules in detail).

The migration has to be a strangler, never a rewrite, because this code moves money. Start by characterising the existing behaviour: capture production traffic and build a golden-file test suite from real request and response pairs, because the current behaviour including its bugs is the specification you must not accidentally change. Then introduce the port and a single adapter for the highest-volume provider, leaving the giant method still in charge, and run the new path in shadow mode where both execute, the results are compared, divergences are logged, and only the old path actually acts. Promote the new path behind a flag per provider and country slice, smallest volume first, with instant rollback available. Delete the old branch only once shadow divergence has been zero for a full billing cycle rather than a full day, because refunds and chargebacks arrive with a long tail.

Extract the cross-cutting logic into decorators only after two providers are behind the port, so that the shared behaviour is derived from two real cases instead of guessed from one. Finally, ratchet the improvement with a lint rule banning new provider names in the legacy file and a contract test suite that every adapter must pass, including tests for idempotency under retry and for correct error classification, since those are the two properties whose absence caused the incident.

How you prove it worked: chargeback rate broken down by provider, duplicate-charge count (which should be structurally zero once idempotency is a shared decorator), shadow-mode divergence count trending to zero, and time to add a new provider dropping from weeks to days.

The sentence for the design review: the bug is not the three thousand lines, it is that retry and 3-D Secure were written per branch when they should have been written per platform, so we put providers behind one port, turn cross-cutting policy into decorators applied once in a documented order, route with an explicit strategy, and migrate under shadow traffic with golden tests, because the existing behaviour is the specification until proven otherwise.

Flashcards

FlashStrategy in one line

A family of interchangeable algorithms behind one interface, chosen at runtime by the caller. The trigger is a switch where every branch does the same job in a different way.

FlashStrategy: the rule everything depends on

The context must never pick its own strategy. Choose at the edge with a Record<Union, factory> map. A switch inside the context has only moved the problem, not removed it.

FlashStrategy versus State

Same class diagram, opposite driver. Strategy is picked from outside and stays put, and the strategies do not know each other. State changes itself as events arrive, and states know their own transitions.

FlashStrategy: class or function?

A role with one method is really a function type, and a closure carries what a constructor would. Use classes when the strategy needs metadata, several methods, or an identity you can store.

FlashStrategy: when one algorithm needs extra input

Give that strategy its own dependency in its constructor (the usual answer), or pass one cohesive context object, or put the data on the domain object, or split into two roles.

FlashStrategy: common mistakes

Building it for one implementation just in case · a class per price point instead of a parameter · a bloated interface · sharing stateful strategies · instanceof checks in the context · naming it IXStrategy.

Scenario Drill

DrillDesign the pricing module for a ride-hailing app: base fare by city, surge, per-minute and per-kilometre rates, promotions, tolls, airport fees and driver payout. Rates change per city and per experiment, and finance must be able to explain any fare. Show the decomposition and name the traps.

The trap this question sets is to create one PricingStrategy class per city. That approach produces fifty near-identical classes, and the first time a rule that should be shared needs to change, you get a copy-paste bug in the three cities somebody forgot to update. The correct decomposition separates the pipeline from the rules from the configuration.

First, a fare is a pipeline of components, not a single algorithm. Model it as FareComponent { compute(trip, ctx): Money; readonly code: string } and build the fare from an ordered list: base fare, time charge, distance charge, surge multiplier, tolls, airport fee, promotion, minimum fare floor and rounding. Each component is a strategy, and the engine runs them in order while accumulating a breakdown rather than just a total. That breakdown is what makes the requirement "finance must be able to explain any fare" actually achievable, because the explanation is produced as a natural side effect of computing the fare rather than reconstructed afterwards from incomplete information.

Second, multiplicative components need explicit ordering rules. Surge multiplies the time-plus-distance subtotal, but it must not multiply tolls or airport fees, because both regulators and riders object to paying surge on a bridge toll. The minimum-fare floor applies after surge but before promotions in most markets. So each component carries a phase — additive, multiplicative, floor, discount or passthrough — and the engine applies the phases in a fixed, documented order. That ordering is a product decision written into code, and getting it wrong produces a revenue shortfall or a compliance incident rather than a mere bug.

Third, differences between cities are configuration, not classes. The base fare component is one class parameterised by a rate card containing base, per-minute rate, per-kilometre rate, minimum, currency and rounding rule, loaded per city and per vehicle tier. Adding a city means adding a row. A city with a genuinely different kind of rule, such as a regulated flat airport fare or a per-zone tariff, gets a new component type registered for that city only. The result is that your class count grows with the number of distinct rule kinds, which is a handful, rather than with the number of cities, which is hundreds.

Fourth, surge is its own strategy with its own inputs. Define SurgeStrategy { multiplier(geohash, time): number } with implementations for supply-and-demand ratio, a manual override for known events, and a capped variant for markets that legally limit surge pricing. It needs live data, so it receives a repository in its constructor, which is the section 6 rule applied in practice. It also needs a cap and a smoothing rule, so that a brief supply dip cannot spike the multiplier to an absurd number.

Fifth, promotions reuse the discount-engine design of strategies loaded from data with an explicit stacking policy and hard caps. The important constraint here is that a promotion must reduce the rider's fare without reducing the driver's payout, which is why payout is computed from the pre-promotion fare.

Sixth, driver payout is its own pipeline that shares components. Payout is a function of the fare before promotion, the commission policy, incentives and guarantees, where CommissionPolicy is a strategy that might be a flat percentage, a tiered rate, or a per-city regulated cap. Modelling payout as a separate pipeline is what prevents the classic and very damaging bug where a rider-facing promotion silently reduces what the driver earns.

Seventh, quote first and charge later, and store the quote. The rider sees an upfront price, but the actual trip may differ from the estimate. So the flow is to compute a Quote containing the full component breakdown, the rate-card version, the surge multiplier and an expiry time, persist it, and then at the end of the trip compute the actual fare using the same pinned versions before applying the upfront-price policy. Pinning the rate-card and strategy versions into the quote is the single most important detail in this whole design, because without it a configuration change that lands mid-trip makes the fare impossible to explain or reproduce afterwards.

Eighth, experiments are strategy selection rather than branches. An experiment swaps one component implementation or one rate card for a bucketed cohort, and the assignment is recorded inside the quote, so any fare remains reproducible from its recorded inputs forever, even years later when the experiment itself has been deleted.

Ninth, testing. Unit tests per component. Golden-file tests per city built from real trips. Property-based tests asserting that the fare is never below the minimum, that a fare with surge is never lower than the same fare without it, and that the components sum exactly to the total when computed in integer minor units, which connects to the money and rounding discussion in 9.1. On top of that, a simulator that replays yesterday's real trips against a proposed rate card, so that the revenue impact of a pricing change is known before it ships rather than discovered afterwards.

Traps worth naming out loud: a class per city, floating-point money, surge applied to fees, recomputing at the end of the trip using current configuration instead of the pinned version, promotions eating into driver payout, and returning a total with no breakdown, which turns every finance question into an archaeology project.

The summary sentence: fares are a phased pipeline of small strategies over versioned rate cards, producing an auditable breakdown that is pinned into a persisted quote, where cities and experiments are data, distinct rule kinds are classes, and the ordering of the multiplicative and discount phases is a documented product decision, because that ordering is exactly where money and compliance live.