Back to the path
Level 3 intermediate 26 min #strategy-pattern#behavioral-patterns#design-patterns#ocp#composition#lld#interview-prep

The Strategy Pattern: Interchangeable Algorithms at Runtime

Encapsulate a family of algorithms behind a common interface and swap them at runtime. We build a real payment-routing engine, contrast Strategy with State and Template Method, and ground it in OCP and composition-over-inheritance.

Intent

Strategy defines a family of interchangeable algorithms, encapsulates each one behind a common interface, and lets the algorithm vary independently of the client that uses it.

That one line hides the whole point: the thing that changes (the algorithm) is pulled out of the thing that stays (the orchestration around it), so you can add, swap, or test algorithms without touching the caller.

The Problem

You start with one way of doing something. Then the business gives you a second way. Then a third. The naive evolution looks like this:

class Checkout {
  pay(method) {
    if (method === "card")        { /* 30 lines of Stripe logic */ }
    else if (method === "upi")    { /* 25 lines of UPI logic */ }
    else if (method === "wallet") { /* 20 lines of wallet logic */ }
    else if (method === "emi")    { /* 40 lines of EMI logic */ }
    // ...and this method keeps growing
  }
}

This is the classic conditional sprawl smell. Every new payment method edits the same method, so:

  • The class violates OCP — you reopen Checkout for every new behavior.
  • The class violates SRP — it now knows the internals of card, UPI, wallet, and EMI processing.
  • Branches share mutable locals and accidentally couple. A change to the wallet branch can break the card branch in code review nobody reads carefully.
  • You can’t unit-test “EMI logic” in isolation; you have to drive it through the giant method.
  • Two methods (pay, refund) both grow parallel if/else ladders that must stay in sync.

The inheritance “fix” is just as bad: a CardCheckout extends Checkout, UpiCheckout extends Checkout hierarchy locks the algorithm into the object’s type at construction. You cannot change a user’s payment method mid-session without rebuilding the object, and you cannot combine a payment algorithm with an orthogonal axis (say, a fraud-check algorithm) without a combinatorial explosion of subclasses.

The trigger for Strategy is: “I have one job, but several interchangeable ways to do it, and the choice should be data, not a new keyword.”

Structure

Strategy splits the world into three roles. The Context holds a reference to a Strategy interface; concrete strategies implement it; the client picks which concrete strategy the context gets.

            ┌─────────────────────────┐        ┌───────────────────────────┐
            │        Context          │        │   «interface» Strategy    │
            ├─────────────────────────┤        ├───────────────────────────┤
            │ - strategy: Strategy    │◇──────▶ │ + execute(ctx): Result    │
            ├─────────────────────────┤  has-a └────────────▲──────────────┘
            │ + setStrategy(s)        │                     │ implements
            │ + doWork(): Result      │          ┌──────────┼──────────────┐
            │     → strategy.execute()│          │          │              │
            └─────────────────────────┘   ┌──────┴───┐ ┌────┴─────┐ ┌──────┴────┐
                       ▲                   │StrategyA │ │StrategyB │ │StrategyC  │
                       │ uses              ├──────────┤ ├──────────┤ ├───────────┤
                 ┌─────┴──────┐            │+execute()│ │+execute()│ │+execute() │
                 │   Client   │            └──────────┘ └──────────┘ └───────────┘
                 └────────────┘

The diamond () is the load-bearing detail: the Context composes a Strategy (has-a), it does not inherit an algorithm (is-a). That single arrow is why Strategy is the canonical demonstration of composition over inheritance.

Participants

RoleResponsibility
Strategy (interface)Declares the operation common to all algorithms. The contract the Context calls through.
ConcreteStrategyOne specific algorithm implementing the interface. Self-contained and independently testable.
ContextHolds a Strategy reference, delegates the varying work to it, and exposes a stable API to the client. May pass itself or relevant data into execute.
ClientDecides which ConcreteStrategy the Context should use, and injects it (constructor, setter, or factory/registry).
Key idea

The Context is not a passive wrapper. It owns the invariant workflow (validation, logging, transaction boundaries) and delegates only the variable decision to the Strategy. Getting this boundary right — fixed shell, swappable core — is the whole skill.

A Real Example: Payment Routing, Not a Toy

The textbook example is “sort an array with different comparators.” Real systems use Strategy for payment routing: given a charge, pick a processor (Stripe, Razorpay, in-house wallet), each with its own fee model, idempotency rules, and failure handling. The Context (PaymentService) owns the invariant parts — validating the request, recording an audit log, normalizing the result — and delegates only the processor-specific call.

A note before the code: real payment fees use integer or decimal arithmetic, never floats, and a single explicit rounding rule. Below, every language computes the Stripe fee the same way — floor(amountMinor * 29 / 1000) plus a flat 30 — using integer math so the three tabs produce bit-identical results. (The earlier float-plus-round() version produced different fees on .5 boundaries because round(), Math.round(), and std::lround() use three different rounding modes. Don’t ship that.)

Watch out

Money math is a substitutability trap. If StripeStrategy rounds half-up and WalletStrategy truncates, two strategies behind the same interface produce different totals for the same input — an LSP violation hiding in a rounding mode. Pin the rounding rule in the interface contract, not per-class. Use integer minor units; reach for a decimal type only when you genuinely need sub-unit precision.

interface PaymentResult {
ok: boolean;
reference: string;
feeMinor: number; // fee in minor units (paise/cents)
}

interface ChargeRequest {
amountMinor: number;
currency: string;
idempotencyKey: string;
}

// --- Strategy ---
interface PaymentStrategy {
readonly name: string;
charge(req: ChargeRequest): Promise<PaymentResult>;
}

class StripeStrategy implements PaymentStrategy {
readonly name = "stripe";
async charge(req: ChargeRequest): Promise<PaymentResult> {
  // 2.9% + 30, integer math, floor — deterministic across languages
  const fee = Math.floor((req.amountMinor * 29) / 1000) + 30;
  // ... real SDK call keyed by req.idempotencyKey ...
  return { ok: true, reference: `ch_${req.idempotencyKey}`, feeMinor: fee };
}
}

class WalletStrategy implements PaymentStrategy {
readonly name = "wallet";
async charge(req: ChargeRequest): Promise<PaymentResult> {
  // internal ledger debit, no external fee
  return { ok: true, reference: `wl_${req.idempotencyKey}`, feeMinor: 0 };
}
}

// --- Context: owns the invariant workflow ---
class PaymentService {
constructor(private strategy: PaymentStrategy) {}

setStrategy(s: PaymentStrategy): void { this.strategy = s; }

async pay(req: ChargeRequest): Promise<PaymentResult> {
  if (req.amountMinor <= 0) throw new Error("amount must be positive");
  console.log(`[audit] charging via ${this.strategy.name}`);
  const result = await this.strategy.charge(req); // the only variable step
  console.log(`[audit] ref=${result.reference} fee=${result.feeMinor}`);
  return result;
}
}

// --- Client selects strategy from data, guarding the unknown key ---
const registry: Record<string, PaymentStrategy> = {
stripe: new StripeStrategy(),
wallet: new WalletStrategy(),
};

// Safe under noUncheckedIndexedAccess: the lookup can miss.
function getStrategy(key: string): PaymentStrategy {
const s = registry[key];
if (!s) throw new Error(`unknown payment strategy: ${key}`);
return s;
}

const svc = new PaymentService(getStrategy("wallet"));

For amountMinor = 1000, all three tabs return fee = floor(1000*29/1000) + 30 = 29 + 30 = 59 — identical, because integer math removes the rounding-mode ambiguity entirely.

Notice what the Context kept: amount validation, audit logging, result normalization. Those are invariant across every processor. Only charge() varies. If tomorrow you add RazorpayStrategy, you write one class and register it — PaymentService does not change. That is OCP made literal.

Strategies as plain functions

When a strategy has no state and one method, an interface is ceremony. In TS and Python a strategy is often just a function — (req) => Result — stored in a map. This is the function-object form of Strategy and is the idiomatic choice for stateless cases like comparators or compression codecs.

type Compressor = (data: Uint8Array) => Uint8Array;

const codecs: Record<string, Compressor> = {
none: (d) => d,
gzip: (d) => gzipSync(d),   // from a real lib
lz4:  (d) => lz4Compress(d),
};

function getCodec(key: string): Compressor {
const c = codecs[key];               // Compressor | undefined under
if (!c) throw new Error(`unknown codec: ${key}`); // noUncheckedIndexedAccess
return c;
}

class Archive {
constructor(private compress: Compressor) {}
write(data: Uint8Array): Uint8Array {
  return this.compress(data); // strategy is a first-class value
}
}

// pick by config / file size / CPU budget — at runtime, key-checked
const arc = new Archive(getCodec(process.env.CODEC ?? "gzip"));
Tip

In an interview, say this explicitly: “In a language with first-class functions, the lightest Strategy is a function in a lookup table; I’d only introduce an interface and concrete classes when a strategy carries state or has more than one method.” And always show you handle the unknown key — a registry that dispatches on user/config input must fail loud on a miss, not hand back undefined. It shows you know the pattern’s intent, not just its UML.

When to Use It

Reach for Strategy when all of these hold:

  • You have multiple interchangeable ways to do one job (route a payment, sort, compress, price, retry-backoff).
  • The choice should be made at runtime — from config, user input, A/B flags, or load conditions — not fixed at compile time.
  • The algorithms are mutually independent; each can be reasoned about and tested alone.
  • You expect the set of algorithms to grow, and you want new ones to be additive.

Concrete production triggers: pricing/discount engines, retry/backoff policies, fraud-scoring models behind a flag, route planners (fastest/shortest/eco), serialization formats, and rate-limiter algorithms (token bucket vs. sliding window).

When NOT to Use It

Strategy is over-applied more often than it’s missing. Two stable branches that will never grow do not need it.

  • Only two cases that never change → a boolean flag or a simple if is honest and cheaper. Don’t add an interface, two classes, and a registry to hide one if.
  • Strategies aren’t actually interchangeable → if each “strategy” needs a different method signature or different inputs, you don’t have a family of algorithms; you have different operations. Forcing them behind one interface produces leaky instanceof checks.
  • The variation is over object lifecycle, not algorithm choice → that’s State, not Strategy (contrasted below).
  • Steps differ but the skeleton is identical → if the algorithms share 80% of a sequence and differ only in a few steps, Template Method (inheritance) or a Strategy per varying step fits better than one monolithic strategy (contrasted below).
Common pitfall

The most common failure: a “Strategy” that constantly does if (this instanceof StripeStrategy) in the Context, or strategies that reach back and mutate the Context’s internals. If the Context must know the concrete type, the abstraction has leaked and you’ve gained indirection for nothing.

Strategy vs. State vs. Template Method

These three are constantly confused in interviews because Strategy and State share identical class diagrams, and Strategy and Template Method solve the same “vary one piece of an algorithm” problem with opposite mechanisms. Know the distinctions cold.

Strategy vs. State

Structurally, State is Strategy: a Context delegates to a swappable object behind an interface. The difference is entirely about who swaps it and why:

StrategyState
What variesThe algorithm for one jobThe behavior across a lifecycle
Who swapsThe client picks, usually onceThe states themselves trigger transitions
AwarenessStrategies are independent, ignorant of each otherStates know their successors (Pending → Paid → Refunded)
Mental model”Pick how to do it""Behave according to what I am right now”

The tell: if WalletStrategy.charge() ever sets service.setStrategy(somethingElse), you’ve drifted from Strategy into State. In Strategy, a strategy never decides what the next strategy is — that’s the client’s job. In State, transitions live inside the state objects.

Litmus test for an interview: “Does the swappable object change the Context’s swappable object? If yes, it’s State. If the choice comes from outside and stays fixed for the operation, it’s Strategy.”

Strategy vs. Template Method

Both let you vary part of an algorithm while keeping a fixed skeleton. They differ in mechanism, and that drives every trade-off:

StrategyTemplate Method
MechanismComposition (has-a, delegate)Inheritance (is-a, override)
BindingRuntime — swap the objectCompile time — fixed by subclass
GranularityReplaces a whole algorithmOverrides specific steps (hooks)
Skeleton lives inThe ContextThe base class’s template method
Reuse of fixed stepsContext calls them around the strategyBase class implements them once

Template Method puts the invariant skeleton in a base class with protected hook methods subclasses override. Strategy puts the skeleton in the Context and delegates the whole varying part to a composed object. Rule of thumb: if the variation is a few small steps inside one fixed sequence, Template Method is less plumbing. If the variation is the entire algorithm, or you need to swap it at runtime, use Strategy.

Key idea

The Gang of Four line: Strategy lets the algorithm vary independently of clients that use it; Template Method lets subclasses redefine certain steps of an algorithm without changing its structure. Strategy varies the whole algorithm via composition; Template Method varies steps via inheritance. State varies behavior over a lifecycle, with transitions owned by the states.

Relationship to SOLID

  • Open/Closed (OCP) — the headline win. New algorithm = new class implementing the interface + a registry entry. Existing, tested code is untouched. This is the textbook mechanism by which OCP is achieved.
  • Single Responsibility (SRP) — each strategy owns exactly one algorithm; the Context owns orchestration. The giant if/else violated SRP; Strategy restores it.
  • Dependency Inversion (DIP) — the Context depends on the Strategy abstraction, not concrete processors. Concrete strategies are injected, so high-level policy doesn’t depend on low-level detail.
  • Liskov (LSP) — every concrete strategy must be a true drop-in: same contract, no surprising side effects, no narrowing of inputs. The Stripe-vs-Wallet difference is exactly the divergence to watch: if Stripe charges a fee and rounds one way while Wallet charges nothing and another processor truncates differently, the strategies are no longer freely substitutable. Pin the contract (units, rounding, currency handling) in the interface so any strategy honoring it is a safe drop-in.
  • Interface Segregation (ISP) — keep the Strategy interface minimal (one method, ideally). A fat strategy interface forces concrete strategies to implement (or stub) methods they don’t need, which is the smell ISP warns against.

And the design-principle headline: Strategy is the executable form of “favor composition over inheritance” plus “program to an interface, not an implementation.” Those two GoF maxims are the entire pattern. When an interviewer asks why Strategy, the strongest answer ties it back to OCP (additive change) and composition (runtime flexibility without subclass explosion) — not to the UML.

Tip

A senior-level closing line for the whiteboard: “I reach for Strategy when the choice is data, the algorithms grow, and I want new ones to be additive. I keep the interface to a single method, pin units and rounding in its contract for substitutability, and select via a registry that fails loud on unknown keys. If the swapped object started driving its own transitions, I’d recognize that as State instead.”

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. In the payment-routing example, which responsibility correctly belongs to the Context (PaymentService) rather than to a ConcreteStrategy?

2. You have a swappable object behind a Context interface. Inside one of those objects, a method calls `context.setStrategy(nextObject)` to move the Context forward. Which pattern is this, and why?

3. Which of the following are legitimate reasons to choose Template Method OVER Strategy? (Select all that apply.) (select all)

4. Why does the lesson compute the Stripe fee with `floor(amountMinor * 29 / 1000) + 30` in integer math instead of `round(amountMinor * 0.029) + 30`?

Design problem 5

Design a pricing engine for an e-commerce checkout that supports multiple, runtime-selectable pricing policies: a flat promo-code discount, a tiered/bulk discount (more units, lower per-unit price), and a dynamic surge/demand multiplier. The selected policy comes from config or an A/B flag and may differ per request. Specify the Strategy interface, two or more concrete strategies, the Context's invariant responsibilities, how selection happens (including unknown-key handling), and how you keep monetary results substitutable across strategies. Note one case where you would NOT use Strategy here.