Strategy pattern: changing rules without stuffing more branches into checkout Basic
Estimated reading time: 10 min read
The problem
Section titled “The problem”Checkout originally just took the subtotal. Then came member discounts, channel promotions, partner pricing, seasonal campaigns. The checkout() function gradually grew if branches and also mixed in payment calls. Changing one price type risks breaking another; testing one small calculation means building the whole checkout.
The problem is not that if is always bad, but that one behavior varying across many variants sits inside a use case with a different responsibility. If “how to compute the price” has a clear contract, it can be split into interchangeable policies. That is Strategy.
Before and after
Section titled “Before and after”Before: an if-chain inside checkout
function checkout(cart: Cart): number { let amount = cart.subtotal; if (cart.customerTier === 'member') amount = Math.max(0, amount - 10); else if (cart.customerTier === 'partner') amount = amount * 0.8; // ... every new customer type adds another branch // ... and payment and order writes live in the same function return amount;}After: policies behind a contract (this is the demo file)
type PricePolicy = (cart: Cart) => number;
const regularPrice: PricePolicy = (cart) => cart.subtotal;const memberPrice: PricePolicy = (cart) => Math.max(0, cart.subtotal - 10);
function checkout(cart: Cart, policy: PricePolicy): number { const amount = policy(cart); if (!Number.isFinite(amount) || amount < 0) throw new Error('invalid amount'); return amount;}
function selectPolicy(tier: Cart['customerTier']): PricePolicy { return tier === 'member' ? memberPrice : regularPrice;}| If-chain | Strategy | |
|---|---|---|
| Adding a variant | Edit the core function | Add one policy + one line in the selector |
| Testing a formula | Build the whole checkout | Call the policy with input, compare output |
| Shared invariants | Easy to forget in a new branch | Checkout checks once for every policy |
| Where “who uses what” is decided | Scattered through the function | One selector |
Core concepts
Section titled “Core concepts”Three roles:
| Component | Question it answers | Should not |
|---|---|---|
| Selector | Which policy does this customer use? | Recompute the price here |
| Strategy | For this input, what is the amount? | Call the payment gateway or write the order |
| Context (checkout) | How does the workflow continue with the computed amount? | Hold each variant’s formula |
Loading diagram…
Strategy does not make conditions disappear: the selector still branches, but the selection branch lives in one place while each policy holds its own execution rule.
Strategy also does not guarantee perfect Open/Closed: adding a policy still means editing the selector or configuration. The real benefit is a bounded edit scope and independent tests.
In TypeScript, functions are first-class, so a Strategy does not have to be a class. Use a class only when a policy needs several dependencies or its own lifecycle.
Policies should be pure: take input and return a result, without implicitly reading the clock or calling the database. If a campaign start time matters, pass now in the input. The same input then always gives the same output, and the applied price can be reproduced later.
Strategy with NestJS DI
Section titled “Strategy with NestJS DI”When a policy needs dependencies (a price table, a feature flag), let the DI container wire it. A common pattern: register the strategies, collect them into a registry by key, inject the registry into checkout. Illustration (not in the demo file, not run):
export const PRICE_POLICIES = Symbol('PRICE_POLICIES');
export interface PricePolicy { readonly tier: 'regular' | 'member'; price(cart: Cart): number;}
@Injectable()export class RegularPricePolicy implements PricePolicy { readonly tier = 'regular' as const; price(cart: Cart) { return cart.subtotal; }}
@Injectable()export class MemberPricePolicy implements PricePolicy { readonly tier = 'member' as const; constructor(private readonly discounts: DiscountTable) {} price(cart: Cart) { return Math.max(0, cart.subtotal - this.discounts.member()); }}
@Injectable()export class CheckoutService { constructor(@Inject(PRICE_POLICIES) private readonly policies: Map<string, PricePolicy>) {}
total(cart: Cart): number { const policy = this.policies.get(cart.customerTier); if (!policy) throw new Error('no policy for tier ' + cart.customerTier); const amount = policy.price(cart); if (!Number.isFinite(amount) || amount < 0) throw new Error('invalid amount'); return amount; }}
@Module({ providers: [ RegularPricePolicy, MemberPricePolicy, DiscountTable, { provide: PRICE_POLICIES, useFactory: (...list: PricePolicy[]) => new Map(list.map((p) => [p.tier, p])), inject: [RegularPricePolicy, MemberPricePolicy], }, CheckoutService, ],})export class PricingModule {}Notes:
useFactorywith aninjectarray is NestJS’s custom provider mechanism (see Custom providers and the DI container).- Adding a new policy still means editing the module (add a provider and a line in
inject). This is exactly where “Strategy is not perfect Open/Closed”. - If checkout has no policy for a tier, it fails loudly instead of silently falling back to a default.
Strategy, State and Template Method
Section titled “Strategy, State and Template Method”These three patterns are easy to confuse because they all “change behavior behind an interface”. According to Refactoring.Guru:
| Strategy | State | Template Method | |
|---|---|---|---|
| Mechanism | Composition: pass an object/function in | Composition: the context holds a state object | Inheritance: subclasses override steps |
| Who picks the variant | The client/selector, from outside | The states themselves switch the context’s state | Chosen when the class is defined (static) |
| Do variants know each other | No, fully independent | They may (a state knows the next state) | Not applicable |
| Changes at runtime | Yes | Yes (along the lifecycle) | No |
| Example | Pick a pricing formula by tier | Order: pending, paid, shipped | A file processing pipeline with different read/write steps |
Quick rule: if the variant is chosen from outside and variants are interchangeable, use Strategy; if behavior changes with the object’s own lifecycle, use State; if the algorithm skeleton is fixed and only a few steps differ, use Template Method.
Real-world example
Section titled “Real-world example”Run the demo
Section titled “Run the demo”npx --prefix examples ts-node examples/src/strategy-pattern/pricing-demo.tsReal output:
regular 100member 90Try passing a policy that returns NaN or -1: checkout throws invalid amount, meaning the context holds a shared invariant independent of the policy.
The demo uses number to focus on the pattern. For real money, use minor units (safe integers or bigint) or a decimal library with explicit rounding rules, and test the edges: an empty cart, multiple discounts, partial refunds, currency changes, a promotion expiring right at checkout.
When to use / not use
Section titled “When to use / not use”- Use it when there are three or more variants (or more are definitely coming), each variant needs its own tests, or the choice happens at runtime.
- Do not use it for one rare branch with no prospect of growth: a short
ifis clearer. If the selector swells into hundreds of overlapping conditions, revisit the business model or use a rules engine, rather than just moving theifs to another file.
Trade-offs
Section titled “Trade-offs”When Strategy helps
- Each variant is tested on its own with concrete inputs/outputs.
- The workflow core holds no per-channel formulas, reducing the risk of cross-breaking edits.
- Pick a policy at runtime when the applicability conditions genuinely differ.
Added cost
- Adds a contract, a selector and more files; with two short branches an
ifmay be clearer. - Picking the wrong policy is still a business bug even if every strategy passes its tests.
- A contract that is too narrow must change when you need a breakdown, currency or async I/O.
Common mistakes
Section titled “Common mistakes”Strategy review checklist
Section titled “Strategy review checklist”- There are at least three variants (or more are definitely coming) that need separate tests; otherwise a short
ifis clearer. - Policies are pure: take input, return a result; values like
noware passed in, not read implicitly. - Policies do not call the database or payment; computing is separated from writing.
- The policy selection branch lives in one selector (or registry) with its own tests; a missing policy for a key fails loudly.
- The context checks shared invariants (for example a finite, non-negative
amount) for every policy. - Use functions when policies have no state or dependencies; use classes only when DI or a separate lifecycle is needed.
Summary
Section titled “Summary”Self-check:
- When adding a new customer type, what has to change and what should stay the same?
- Why does
checkoutstill validateamounteven though the policy is tested? - Should order states pending, paid, shipped be modeled with Strategy or State, and why?