Behavioral Pattern (GoF)

Strategy

Defines a family of algorithms, encapsulates each of them, and makes them interchangeable — letting the algorithm vary independently from the clients that use it, and be swapped at runtime.

Intent

Encapsulate each algorithm variation in its own class (or function), making them interchangeable. The context that uses the algorithm doesn't know which implementation is active — it just calls the interface method. This allows swapping behavior at runtime without changing the context.

Strategy belongs to the behavioral patterns category in the GoF catalog (Gamma, Helm, Johnson and Vlissides, 1994). It's one of the most applied patterns in practice — often without the developer realizing they're following a named pattern — and is the core mechanism behind many validation, sorting, and routing frameworks.

Problem

Consider an e-commerce system that calculates the shipping cost of an order. At first, there was only one carrier. As the business grew, several options appeared: postal service (standard and express), private carriers, in-store pickup, and expedited delivery. The naive implementation concentrates all the calculation logic in a single class with growing conditionals:

// Naive approach — DON'T do this:
calculateShipping(type: string, weight: number, distance: number): number {
  if (type === "standard") {
    return weight * 0.05 + distance * 0.01;
  } else if (type === "express") {
    return weight * 0.12 + distance * 0.02 + 5;
  } else if (type === "overnight") {
    return weight * 0.20 + distance * 0.03 + 15;
  } else if (type === "pickup") {
    return 0;
  }
  throw new Error(`Unknown type: ${type}`);
}

Every new carrier requires modifying this method: risk of regression, difficulty testing each case in isolation, and a violation of the Open/Closed Principle. On top of that, the calculation logic can't be reused independently — it's stuck inside the conditional.

The Strategy pattern solves this by extracting each calculation algorithm into its own class implementing a common interface. The context (order, cart) receives the strategy via injection and calls it without knowing which one it is.

Solution

Strategy organizes the code into three participants:

  1. Strategy (interface): declares the method every algorithm must implement. E.g.: ShippingStrategy with the method calculate(weight, distance).
  2. ConcreteStrategy: implements the specific algorithm. E.g.: StandardShipping, ExpressShipping, OvernightShipping, StorePickup.
  3. Context: keeps a reference to a Strategy object and delegates the work to it. The context can allow swapping the strategy at runtime through a setter. E.g.: an Order class that stores the shipping strategy and invokes it when calculating the total.

Strategy in TypeScript: functions as strategies

In TypeScript (and JavaScript), functions are first-class citizens — you can pass a function directly as a strategy, without needing to create an extra class. This variant is lighter and more idiomatic in TS for stateless strategies:

// Instead of an interface with a class, the strategy is simply a function type:
type ShippingStrategy = (weight: number, distance: number) => number;

const standardStrategy: ShippingStrategy = (w, d) => w * 0.05 + d * 0.01;
const expressStrategy: ShippingStrategy  = (w, d) => w * 0.12 + d * 0.02 + 5;

// The context accepts any function of the correct type:
class Order {
  constructor(private shippingStrategy: ShippingStrategy) {}

  calculateShipping(weight: number, distance: number): number {
    return this.shippingStrategy(weight, distance);
  }
}

In PHP, first-class functions also exist (callables, closures, arrow functions), but for strategies with state or that need injected dependencies, interfaces with concrete classes are more idiomatic and recommended by PHPStan/Psalm's strict typing.

Structure

          «interface»
        ShippingStrategy
  ┌───────────────────────────────────┐
  │ + calculate(weight, dist): number │
  └───────────────────────────────────┘
              ▲
   ┌──────────┼──────────────────┐
   │          │                  │
StandardShipping  ExpressShipping  StorePickup
(Concrete)        (Concrete)       (Concrete)


              Order (Context)
  ┌──────────────────────────────────────┐
  │ - strategy: ShippingStrategy         │
  │ + setStrategy(s: ShippingStrategy)   │
  │ + calculateShipping(w, dist): number │
  └──────────────────────────────────────┘
              │ uses (delegates to)
              ▼
         ShippingStrategy


Runtime swap flow:

  order.setStrategy(new StandardShipping())
  order.calculateShipping(2, 50)  →  StandardShipping.calculate(2, 50)

  // User switches to overnight delivery:
  order.setStrategy(new OvernightShipping())
  order.calculateShipping(2, 50)  →  OvernightShipping.calculate(2, 50)
  // Without changing Order; without if/else.

Code examples

Example 1 — Shipping cost strategies

A complete implementation with a typed interface, concrete strategies and a context. The strategy can be swapped at any time via a setter.

// ── Strategy interface ────────────────────────────────────────
interface ShippingStrategy {
  calculate(weightKg: number, distanceKm: number): number;
  description(): string;
}

// ── ConcreteStrategies ────────────────────────────────────────
class StandardShipping implements ShippingStrategy {
  calculate(weightKg: number, distanceKm: number): number {
    return weightKg * 0.05 + distanceKm * 0.01;
  }
  description(): string { return "Standard mail (economy)"; }
}

class ExpressShipping implements ShippingStrategy {
  calculate(weightKg: number, distanceKm: number): number {
    return weightKg * 0.12 + distanceKm * 0.02 + 5;
  }
  description(): string { return "Express mail"; }
}

class CarrierShipping implements ShippingStrategy {
  constructor(private readonly baseFee: number) {}

  calculate(weightKg: number, distanceKm: number): number {
    return this.baseFee + weightKg * 0.08 + distanceKm * 0.015;
  }
  description(): string { return `Carrier (base fee: $${this.baseFee})`; }
}

class StorePickup implements ShippingStrategy {
  calculate(_weightKg: number, _distanceKm: number): number { return 0; }
  description(): string { return "Store pickup (free)"; }
}

// ── Context ───────────────────────────────────────────────────
class Order {
  private strategy: ShippingStrategy;

  constructor(strategy: ShippingStrategy) {
    this.strategy = strategy;
  }

  // Allows swapping the strategy at runtime (e.g.: user changes delivery option).
  setShippingStrategy(strategy: ShippingStrategy): void {
    this.strategy = strategy;
  }

  calculateShipping(weightKg: number, distanceKm: number): number {
    return this.strategy.calculate(weightKg, distanceKm);
  }

  summary(weightKg: number, distanceKm: number): void {
    const value = this.calculateShipping(weightKg, distanceKm);
    console.log(
      `${this.strategy.description()}: $${value.toFixed(2)}`
    );
  }
}

// ── Usage ────────────────────────────────────────────────────
const order = new Order(new StandardShipping());
order.summary(3, 200);
// → Standard mail (economy): $2.15

order.setShippingStrategy(new ExpressShipping());
order.summary(3, 200);
// → Express mail: $9.36

order.setShippingStrategy(new CarrierShipping(10));
order.summary(3, 200);
// → Carrier (base fee: $10): $13.24

order.setShippingStrategy(new StorePickup());
order.summary(3, 200);
// → Store pickup (free): $0.00

Example 2 — Strategy as a function (TypeScript) vs callable (PHP)

For simple stateless strategies, functions are lighter than classes. TypeScript treats functions as first-class types; PHP uses callable or closures. The difference matters for testability and for dependency injection.

// Strategy as a function type — no extra classes.
type Comparator<T> = (a: T, b: T) => number;

// Concrete strategies: simple arrow functions.
const byPriceAsc: Comparator<{ price: number }>  = (a, b) => a.price - b.price;
const byPriceDesc: Comparator<{ price: number }> = (a, b) => b.price - a.price;
const byName: Comparator<{ name: string }>       = (a, b) => a.name.localeCompare(b.name);

// Generic context: accepts any strategy compatible with type T.
function sortItems<T>(items: T[], comparator: Comparator<T>): T[] {
  return [...items].sort(comparator);
}

// ── Usage ────────────────────────────────────────────────────
const products = [
  { name: "Pen",     price: 2.50 },
  { name: "Notebook", price: 18.90 },
  { name: "Eraser",  price: 1.20 },
];

console.log(sortItems(products, byPriceAsc).map(p => p.name));
// ["Eraser", "Pen", "Notebook"]

console.log(sortItems(products, byPriceDesc).map(p => p.name));
// ["Notebook", "Pen", "Eraser"]

console.log(sortItems(products, byName).map(p => p.name));
// ["Eraser", "Notebook", "Pen"]

// Inline anonymous strategy — for one-off cases without reuse:
const byNameDesc = sortItems(products, (a, b) => b.name.localeCompare(a.name));
console.log(byNameDesc.map(p => p.name));
// ["Pen", "Notebook", "Eraser"]

When to use

  • When there are multiple variations of an algorithm that need to be interchangeable — shipping cost, discount, validation, sorting, compression, authentication, serialization.
  • When the algorithm needs to vary at runtime: the user chooses the payment method, the delivery option, the export format. With Strategy, the swap is simple — a setter or an injection at construction time.
  • To eliminate repetitive conditionals: if you have an if/else or switch that grows with every new variation and is scattered across several methods, Strategy extracts each branch into its own testable class.
  • To isolate algorithms in unit tests: each strategy can be tested independently, without needing the whole context.

When to avoid

  • When there are only two simple, stable cases: a direct if/else is more readable than creating an interface, two classes and an injection mechanism for something that will never change.
  • When the algorithm has no real variation: if the "strategy" is always the same and no alternative is planned, the pattern adds unnecessary abstraction.
  • When the context needs to know details of each strategy: if the context needs to downcast to figure out which strategy is active and act differently in each case, the abstraction was applied poorly — the conditional logic just came back in disguise.

Pros and cons

Pros

  • Allows swapping algorithms at runtime without changing the context.
  • Isolates each algorithm variation in its own class — easier to test, understand and maintain.
  • Eliminates long conditionals (if/else or switch) that grow with every new variation.
  • Follows the Open/Closed Principle — new strategies don't require modifying the context.
  • Favors composition over inheritance — behavior is injected, not inherited.

Cons

  • Increases the number of classes/objects in the system — overengineering for trivial cases.
  • The client needs to know the available strategies in order to choose and inject the right one.
  • Stateless strategies could be simple functions — creating a full class for that is verbose, especially in PHP.
  • If strategies need a lot of data from the context, the interface can become coupled to the context, reducing flexibility.

Common pitfalls

1. Overengineering for two trivial cases

The most frequent mistake: applying Strategy when there are exactly two cases and no prospect of growth. If the system will always have "free shipping for orders above $100" and "flat $15 shipping for the rest," a simple ternary is more readable than an interface with two classes. Reserve Strategy for when the variation is the real extension point of the design.

Rule of thumb: if you're wondering "is it worth extracting this to Strategy?", first ask whether there are three or more variations — or whether a third one is certain to appear. With two fixed variations, the answer is usually no.

2. Context leaking into the strategy

The strategy interface should receive only the data the algorithm needs — not the entire context. Passing the full Order object to ShippingStrategy.calculate(order) creates bidirectional coupling: the strategy starts depending on the internal structure of the context. Prefer primitive parameters or specific DTOs: calculate(weightKg, distanceKm).

3. State shared across calls

If the strategy accumulates state between calls (e.g.: request count, internal cache), it stops being safe for concurrent environments (Node.js multi-request, multi-threaded servers) or for reuse in tests. Strategies should be stateless whenever possible — or, if they need state, that state should be passed explicitly in the method call.

4. Strategy vs Template Method

Both solve behavior variation, but in opposite ways. Template Method uses inheritance: the base class defines the skeleton and subclasses fill in the variable steps — the algorithm is fixed, only parts of it vary. Strategy uses composition: the entire algorithm is swappable, the context is stable. Prefer Strategy when the whole algorithm varies; prefer Template Method when only parts of the algorithm vary and the overall structure is fixed.

Related patterns

Strategy interacts with several other behavioral and creational patterns:

Factory Method is frequently used to create and inject the correct strategy into the context: the creator selects which ConcreteStrategy to instantiate based on configuration or type, handing it ready to the Context. The State pattern is structurally identical to Strategy — both encapsulate variable behavior in interchangeable objects — but with different intents: Strategy swaps algorithms (the context doesn't know which one is active and doesn't care), while State manages state transitions (the object behaves differently in each state and the transitions are part of the design). Template Method is Strategy's "inheritance cousin": it uses inheritance to vary parts of the algorithm, while Strategy uses composition to vary the whole algorithm.