Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Strategy
LLD

Strategy

Encapsulate interchangeable algorithms and select behavior dynamically without changing the client.

Strategy: Pattern Implementation

The Problem It Solves

Checkout supports card, UPI, and wallet payments. The naive implementation is a switch inside CheckoutService — which grows with every mode, forces the whole service to re-test per addition, and cannot vary payment choice at runtime (per-user preference, feature flags). Strategy extracts each algorithm behind one interface; the context holds a strategy and delegates, blind to which one.

                    ┌────────────────────┐
   CheckoutService ─┤ context: holds ONE │  ── uses ──► PaymentStrategy ◁ interface
   (stable code)    │ PaymentStrategy    │              + pay(Money): Result
                    └────────────────────┘                        △
                                        ┌───────────┬───────────┬┴─────────┐
                                        │ CardPay   │ UpiPay    │ WalletPay│
                                        └───────────┴───────────┴──────────┘
                     swap the injected instance → behavior changes, zero edits here

Full Implementation

interface PaymentStrategy {
    PaymentResult pay(Money amount);
}

class CardPayment implements PaymentStrategy {
    private final CardGateway gateway;
    @Override public PaymentResult pay(Money amount) { return gateway.charge(amount); }
}

class UpiPayment implements PaymentStrategy {
    private final VpaResolver resolver;
    @Override public PaymentResult pay(Money amount) { return resolver.collect(amount); }
}

class WalletPayment implements PaymentStrategy {
    private final WalletStore wallets;
    @Override public PaymentResult pay(Money amount) {
        if (!wallets.hasBalance(amount)) throw new InsufficientFundsException();
        return wallets.debit(amount);
    }
}

// CONTEXT — closed forever:
class CheckoutService {
    private final Map<PaymentType, PaymentStrategy> strategies;

    CheckoutService(Map<PaymentType, PaymentStrategy> strategies) { this.strategies = strategies; }

    PaymentResult checkout(Cart cart, PaymentType type) {
        Money total = cart.total();
        return strategies.get(type).pay(total);      // ← the only variation point
    }
}

New mode (BNPL) = new class + one registry entry. CheckoutService untouched since birth.

Runtime vs Construction-Time Selection

WiringBehavior
Constructor injectionStrategy fixed for object lifetime (most common)
Setter/param injectionSwitchable per transaction (e.g., user picks mode at runtime)
Registry map + request paramBoth: strategies fixed set, selection dynamic

Real-World Sightings

  • Collections.sort(list, Comparator) — sort is context, every comparator a strategy. The canonical JDK case.
  • ThreadPoolExecutor.RejectedExecutionHandler — abort/caller-runs/discard policies.
  • Spring’s AuthenticationProvider list; servlet container connection ProtocolHandlers.

Trade-offs

GainCost
Open-closed growth on algorithmsClient must know strategy set
Each algorithm unit-testable in isolationIndirection when reading flow
Kills conditional sprawlMore classes than an if-chain (until variant #3)
Runtime swappabilityStateful strategies need care under concurrency

Beyond the Interface

The previous page wired strategies; this page covers the engineering decisions that make them pay off in real codebases: eliminating conditionals systematically, isolating algorithm tests, managing strategy state, and choosing between classes and lambdas.

Conditional Elimination — the Systematic Form

// BEFORE: every routing rule change edits shipping cost logic
Money shippingCost(Cart c) {
    if (c.isPrime()) return Money.ZERO;
    if (c.weight() > 10) return Money.of(199);
    if (c.dest().isRemote()) return Money.of(299);
    return Money.of(49);
}

// AFTER: rules become data + strategy objects
interface ShippingRule { Optional<Money> charge(Cart c); }

class PrimeFreeShipping implements ShippingRule {
    public Optional<Money> charge(Cart c) {
        return c.isPrime() ? Optional.of(Money.ZERO) : Optional.empty();
    }
}
class WeightTier implements ShippingRule { ... }   // etc.

Money shippingCost(Cart c) {
    for (ShippingRule r : rulesInPriorityOrder)     // order = business policy,
        var m = r.charge(c);                        // configurable, not compiled
        if (m.isPresent()) return m.get();
    return defaultRate;
}

Rules become an ordered list — priority is configuration, not nested ifs. Adding a rule cannot disturb existing ones.

Testing Isolation

Each strategy tests alone with zero context scaffolding:

@Test void primeUsersShipFree() {
    var cart = TestCart.prime().build();
    assertEquals(Optional.of(Money.ZERO), new PrimeFreeShipping().charge(cart));
}

No mocking of checkout internals; the conditional-tangled original needed the full cart+user+destination fixture per case.

Strategy State & Concurrency

Strategy kindConcurrency stance
Stateless (pure functions of input)Share freely; thread-safe by construction
Configured-immutable (holds injected deps, never mutates)Share freely
Stateful (counters, caches inside)Per-use instance or internal synchronization

Default to stateless; document any deviation on the interface.

Class vs Lambda Decision

// trivial one-method strategy → lambda collapses ceremony:
strategies.put(PaymentType.UPI, amount -> upiResolver.collect(amount));

// multi-method contract or meaningful state → keep the class:
class FraudCheckingPayment implements PaymentStrategy { ... }

Rule of thumb: lambdas until a second method, injected dependency graph, or testability need forces a named type.

Failure Modes

  • Parameter explosion: strategies whose execute(ctx) takes a 12-field context map — the interface leaked; model proper parameter objects.
  • Hidden ordering dependencies: strategies assuming earlier ones ran — encode pipeline position explicitly.
  • Selection logic regrowing elsewhere: a switch mapping enum→strategy belongs at composition root only.

The Problem It Solves

Ctrl+Z is a product requirement in editors, design tools, and admin consoles — and it’s hard precisely because undo needs to restore exact prior state including side effects. Command objects make undo tractable: every mutation is an object that can remember how to reverse itself, and history becomes a stack of those objects.

The Two Undo Strategies

 STRATEGY A: INVERSE COMMANDS              STRATEGY B: STATE SNAPSHOTS

 execute() stores enough to run            execute() captures full prior
 the opposite operation later              state (memento); undo restores it

 AddText("hello")                          DocumentState(before)
   undo → RemoveText("hello")                undo ← swap state back
 cheap per operation; inverse logic        always correct; memory-heavy
 must be written & correct per command     for any object with snapshot()
Inverse commandsSnapshots
Memory per stepSmall (diffs/params)Full state size
Correctness riskInverse bugs accumulateNone if capture is complete
Best forStructured edits (text ops, moves)Small documents, config toggles

Mechanics (inverse-command form)

interface Command {
    void execute();
    void undo();
}

class InsertTextCommand implements Command {
    private final TextBuffer doc;
    private final String text;
    private final int at;

    @Override public void execute() { doc.insert(at, text); }
    @Override public void undo()   { doc.delete(at, text.length()); }
}

class History {
    private final Deque<Command> undoStack = new ArrayDeque<>();
    private final Deque<Command> redoStack = new ArrayDeque<>();

    void run(Command c) {
        c.execute();
        undoStack.push(c);
        redoStack.clear();               // new edit invalidates the redo branch
    }
    void undo() {
        var c = undoStack.poll();
        if (c == null) return;
        c.undo();
        redoStack.push(c);
    }
    void redo() {
        var c = redoStack.poll();
        if (c == null) return;
        c.execute();                     // NOT undo() — re-run forward
        undoStack.push(c);
    }
}

Three invariants carry correctness: undo reverses in exact LIFO order; redo re-executes forward (never calls undo again — that would invert twice); any fresh edit clears the redo stack (the branching timeline is deliberately discarded).

Snapshot Form (Memento pairing)

class SetPriceCommand implements Command {
    private final Product p; private final Money oldPrice, newPrice;
    static SetPriceCommand of(Product p, Money np) {
        return new SetPriceCommand(p, p.price(), np);   // capture BEFORE mutating
    }
    public void execute(){ p.setPrice(newPrice); }
    public void undo()   { p.setPrice(oldPrice); }
}

Capture-before-mutate ordering is where undo bugs are born: capturing after the change records nothing useful.

Production Concerns

  • Memory bound (illustrative math): 100-step history × 50 KB snapshots ≈ 5 MB — fine; 1 MB documents × unlimited steps is not. Cap history length or switch to diffs/coalescing.
  • Coalescing: holding a key down generates thousands of insert commands — merge adjacent same-type commands into one undo unit.
  • External side effects: emails sent, payments charged cannot be “un-done” by inverse execution — such operations need compensating actions (saga territory) or must be excluded from trivial undo.
  • Multi-user editing: local stacks break under concurrent edits; OT/CRDT systems replace simple stacks.

Interview Framing

  • Being handed “add undo to my editor” and producing the two-stack structure with the three invariants above is the expected full answer.

My Private Notes

Notes are auto-saved locally to this device.