Strategy Pattern
Strategy is the answer to a specific smell: one algorithm with several variants, selected at runtime, where every new variant edits the same function.
It is not "an interface with two implementations". It is a way to make the choice explicit and keep each policy independently testable.
The problem
function shippingCost(order: Order, method: string): Money {
switch (method) {
case "standard":
return order.weightKg <= 5 ? usd(5) : usd(5 + (order.weightKg - 5) * 0.5);
case "express":
return usd(15 + order.weightKg * 1.2);
case "overnight":
return usd(30 + order.weightKg * 2) + (order.saturday ? usd(20) : usd(0));
case "pickup":
return usd(0);
default:
throw new Error(`unknown shipping method: ${method}`);
}
}
This function is edited by operations (prices), logistics (zones), and marketing (free shipping). It mixes selection with policy. Testing one method requires constructing orders for all branches.
Structure
flowchart LR
C[Checkout] --> P[ShippingPolicy]
P --> S[Standard]
P --> E[Express]
P --> O[Overnight]
P --> U[Pickup]
The context (Checkout) depends on the abstraction. The strategies implement one policy each. Selection happens at the edge (config, request, feature flag), not inside the algorithm.
Function-based strategy (start here)
In TypeScript a strategy is often just a function:
export type ShippingPolicy = (order: Order) => Money;
export const standardShipping: ShippingPolicy = (order) =>
order.weightKg <= 5
? usd(5)
: usd(5 + (order.weightKg - 5) * 0.5);
export const expressShipping: ShippingPolicy = (order) =>
usd(15 + order.weightKg * 1.2);
export const overnightShipping: ShippingPolicy = (order) =>
usd(30 + order.weightKg * 2).plus(order.saturday ? usd(20) : usd(0));
export const pickup: ShippingPolicy = () => usd(0);
Dispatch in one place:
const SHIPPING: Record<string, ShippingPolicy> = {
standard: standardShipping,
express: expressShipping,
overnight: overnightShipping,
pickup,
};
export function shippingPolicyFor(method: string): ShippingPolicy {
const policy = SHIPPING[method];
if (!policy) throw new UnknownShippingMethod(method);
return policy;
}
Callers:
class Checkout {
constructor(private readonly shipping: ShippingPolicy) {}
total(order: Order): Money {
return sum(order.lines).plus(this.shipping(order));
}
}
No classes, no this, no ceremony. Each policy is a pure function: trivial to test, compose, and memoize.
Class-based strategy
Use a class when the policy has configuration or collaborators:
interface DiscountStrategy {
apply(amount: Money, cart: Cart): Money;
}
class PercentageDiscount implements DiscountStrategy {
constructor(private readonly percent: number) {}
apply(amount: Money): Money {
return amount.times(1 - this.percent / 100);
}
}
class CouponDiscount implements DiscountStrategy {
constructor(private readonly coupons: CouponReader) {}
async apply(amount: Money, cart: Cart): Promise<Money> {
const coupon = await this.coupons.find(cart.couponCode);
return coupon ? amount.minus(coupon.value) : amount;
}
}
If apply becomes async, note that the interface changes for everyone. Function strategies have the same issue; choose sync where possible.
Selection belongs at the edge
A common mistake is injecting a "strategy selector" into the domain:
// Avoid: the domain now knows about plans and config.
class Checkout {
constructor(private readonly config: Config) {}
total(order: Order) {
return order.total.plus(pickShipping(this.config.shippingMethod)(order));
}
}
Prefer resolving the strategy at the composition root or controller:
const policy = shippingPolicyFor(request.body.shippingMethod);
const checkout = new Checkout(policy);
The domain receives the decision, not the decision-making machinery.
Composition over a strategy per combination
Discounts combine independently of shipping methods. Do not create ExpressWithBlackFridayPolicy:
type PriceRule = (amount: Money, cart: Cart) => Money;
const blackFriday: PriceRule = (amount) => amount.times(0.7);
const loyalty: PriceRule = (amount, cart) =>
cart.customer.loyal ? amount.times(0.95) : amount;
function applyRules(amount: Money, cart: Cart, rules: PriceRule[]): Money {
return rules.reduce((current, rule) => rule(current, cart), amount);
}
const price = applyRules(subtotal, cart, [blackFriday, loyalty]);
Ordering becomes an explicit, testable decision — which matters for money.
Strategy vs. other patterns
| Need | Better fit |
|---|---|
| Choose one algorithm at runtime | Strategy |
| Create the strategy from config/type | Factory |
| React to events, not replace an algorithm | Observer |
| Wrap a call with retry/cache/auth | Decorator |
| Behaviour changes with internal state over time | State |
Strategy is about replacement; State is about transitions driven by the object, Observer is about notification.
Trade-offs
Gains
- one policy per file, independently testable;
- dispatch is explicit and exhaustively checkable;
- adding a variant does not reopen existing policies;
- policies can be composed (rules) or injected (tests).
Costs
- the caller must know how to select (or a factory must);
- indirection when there is only one strategy;
- stateful strategies become hidden shared state — keep them stateless or scope them per request;
- too many tiny strategies can fragment a coherent rule.
Do not introduce Strategy for:
- two branches that will never grow;
- selection based on a single boolean that can be a parameter;
- algorithms that differ only in a constant (
fee = 5vsfee = 10).
Testing
test("overnight adds Saturday surcharge", () => {
const order = orderWith({ weightKg: 2, saturday: true });
expect(overnightShipping(order)).toEqual(usd(30 + 2 * 2 + 20));
});
test("unknown method is a typed error", () => {
expect(() => shippingPolicyFor("teleport")).toThrow(UnknownShippingMethod);
});
If strategies share a contract (idempotency, monotonicity, never negative), write a contract suite and run it for each. That catches a policy that returns a negative total only under a rare combination.
Exercises
- Replace a
switchon a domain type with a map of functions. Add a new variant and count edited files. - Decide which discounts should compose vs. which are mutually exclusive; model both.
- Move strategy selection from a service into the composition root.
- Write a contract test that every
ShippingPolicyreturns non-negative money and zero for an empty order.
Checklist
- Each strategy implements exactly one policy.
- Selection is explicit and central (map, factory, or injected decision).
- Strategies are stateless or request-scoped.
- The common path does not require reading five files.
- Adding a variant touches one new file plus one registration.
- A contract test runs against every strategy.
- A simple parameter or function was ruled out first.
Next: Observer — decoupling notification without creating a memory leak.