Skip to content
Knowledge Hub

Strategy pattern: changing rules without stuffing more branches into checkout Basic

Estimated reading time: 10 min read

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: 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

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.

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:

  • useFactory with an inject array 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.

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.

Terminal window
npx --prefix examples ts-node examples/src/strategy-pattern/pricing-demo.ts

Real output:

regular 100
member 90

Try 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.

  • 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 if is clearer. If the selector swells into hundreds of overlapping conditions, revisit the business model or use a rules engine, rather than just moving the ifs to another file.

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 if may 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.
  1. There are at least three variants (or more are definitely coming) that need separate tests; otherwise a short if is clearer.
  2. Policies are pure: take input, return a result; values like now are passed in, not read implicitly.
  3. Policies do not call the database or payment; computing is separated from writing.
  4. The policy selection branch lives in one selector (or registry) with its own tests; a missing policy for a key fails loudly.
  5. The context checks shared invariants (for example a finite, non-negative amount) for every policy.
  6. Use functions when policies have no state or dependencies; use classes only when DI or a separate lifecycle is needed.

Self-check:

  1. When adding a new customer type, what has to change and what should stay the same?
  2. Why does checkout still validate amount even though the policy is tested?
  3. Should order states pending, paid, shipped be modeled with Strategy or State, and why?