Strategy Pattern: Swap Behavior Without Touching the Caller
Why a checkout that grows an if-branch every quarter is a design smell — and how the Strategy pattern turns runtime behavior into something you plug in, not rewrite.
There is a method in every mature codebase that everyone edits and nobody understands. On a checkout team, it’s usually processPayment().
It started clean: card, and maybe PayPal. Then Q2 added UPI. Q3 added wallets. Q4, someone in a growth meeting promised crypto, so an else if ("crypto") landed the week before launch. Two years in it’s four hundred lines of if/else if, three of the branches share a helper that mutates a field two branches above rely on, and the last person who tried to reorder them broke refunds. The method works. It is also radioactive — every new tender is a diff to the one function that must never be wrong, and every diff risks the branches you didn’t touch.
The problem isn’t the number of payment methods. It’s that how you pay and the code that decides to charge you live in the same body, so they can’t change independently. The Gang of Four named the fix.
The intuition: the counter doesn’t care how you pay
Walk up to a checkout counter. You can tap a card, scan a QR code, hand over cash, or wave your phone. The cashier runs the same motion every time — total the basket, take the tender, hand back a receipt. They do not have a different personality for each payment method. They delegate the “how” to whatever instrument you present.
That cashier is the context. The instrument is the strategy. The counter stays boring on purpose: keeping it ignorant of the how is exactly what lets a new payment method show up without retraining the cashier.
Why it exists: watch the branches collapse
The whole point is turning a conditional you edit into a set of classes you plug in. Drag the slider below from the tangled processPayment() on the left toward the strategy-based version on the right, and watch the branch pile flatten into a context that does exactly one thing — delegate.
import java.math.BigDecimal;
public PaymentResult processPayment(String type, Money amount) {
if ("card".equals(type)) {
// Tokenise PAN and hit the card network.
return new PaymentResult(true, "Card authorised for " + amount.amount());
} else if ("upi".equals(type)) {
// Raise a UPI collect request against the payer VPA.
return new PaymentResult(true, "UPI collect sent for " + amount.amount());
} else if ("wallet".equals(type)) {
// Debit the stored prepaid balance instantly.
return new PaymentResult(true, "Wallet debited " + amount.amount());
} else if ("crypto".equals(type)) {
// Post an on-chain invoice and await block confirmations.
return new PaymentResult(true, "On-chain invoice posted for " + amount.amount());
} else {
throw new IllegalArgumentException("Unknown payment type: " + type);
}
}
import java.math.BigDecimal;
public record Money(BigDecimal amount, String currency) {}
public record PaymentResult(boolean ok, String detail) {}
public interface PaymentStrategy {
PaymentResult pay(Money amount);
}
public final class CardStrategy implements PaymentStrategy {
@Override public PaymentResult pay(Money amount) {
// Tokenise PAN and hit the card network.
return new PaymentResult(true, "Card authorised for " + amount.amount());
}
}
public final class UpiStrategy implements PaymentStrategy {
@Override public PaymentResult pay(Money amount) {
// Raise a UPI collect request against the payer VPA.
return new PaymentResult(true, "UPI collect sent for " + amount.amount());
}
}
public final class WalletStrategy implements PaymentStrategy {
@Override public PaymentResult pay(Money amount) {
// Debit the stored prepaid balance instantly.
return new PaymentResult(true, "Wallet debited " + amount.amount());
}
}
public final class CryptoStrategy implements PaymentStrategy {
@Override public PaymentResult pay(Money amount) {
// Post an on-chain invoice and await block confirmations.
return new PaymentResult(true, "On-chain invoice posted for " + amount.amount());
}
}
public final class Checkout {
private PaymentStrategy strategy;
public Checkout(PaymentStrategy strategy) { this.strategy = strategy; }
public void setStrategy(PaymentStrategy s) { this.strategy = s; }
public PaymentResult checkout(Money amount) {
return strategy.pay(amount); // pure delegation — no branching ever
}
}
The if ladder didn’t vanish — its responsibility moved. Each branch became a small class that owns one algorithm, and the caller shrank to a single line: strategy.pay(amount).
The structure
Four moving parts, one relationship that matters: the context has-a strategy. It never is one, and it never news up a concrete one in its core logic.
Checkout (the context) holds a reference to the PaymentStrategy interface and delegates pay() to whichever concrete strategy it was handed. Adding a fifth tender means a fifth box on the bottom row — nothing above it changes.
The arrow from Checkout to the interface is the load-bearing one. Because it points at PaymentStrategy and never at CardStrategy, the concrete boxes on the bottom row are free to multiply, and the context above stays frozen.
The code
Three parts, in the order you’d write them: the contract, the concrete strategies that honor it, and the context that delegates to whichever one it holds.
«interface»PaymentStrategy
+pay(amount: Money) → PaymentResult
context · delegates pay()Checkout
−strategy: PaymentStrategy
+setStrategy(strategy: PaymentStrategy) → void
+checkout(amount: Money) → PaymentResult
The contract, the strategies, and the context
java
Read it top to bottom and the discipline is visible. PaymentStrategy is the whole vocabulary the context is allowed to know: pay(Money) → PaymentResult. Each concrete class — card, UPI, wallet, crypto — owns exactly one algorithm and its own private state (a masked PAN, a VPA, a running balance). Checkout holds one field of the interface type and forwards to it. There is no branch in the context, because there is nothing to branch on: it doesn’t know which strategy it’s holding, and that ignorance is the feature.
Now the payoff. The same checkout.checkout(basket) call produces card behavior or UPI behavior depending only on what you injected — and switching mid-flow is a single setter, not an edit to Checkout.
Swapping at runtime
java
See it live
Same context, same checkout() call — pick a tender and watch the delegated behavior change. Nothing about Checkout is different between clicks; only the strategy reference it holds is.
// the Checkout context — unchanged
checkout.strategy = new CardStrategy()
Tokenize the PAN and authorize against the card network.
That’s the pattern’s promise made tangible: behavior selected at runtime, by the caller, with zero conditional logic in the thing doing the work.
When (and when not) to reach for it
The confusion in interviews is almost always Strategy versus its two nearest neighbors. They look alike on a class diagram and differ entirely in intent.
In the wild
You’ve shipped Strategy already, probably without naming it:
The tradeoffs
Strategy is not free. It trades a fat conditional for a wider surface of small classes, and that trade is worth naming honestly.
What you gain
Open/Closed: new behavior is a new class, not an edit to the caller
Each algorithm is unit-testable in isolation, no context needed
Behavior is selectable at runtime, from config or user input
The context loses its conditional — one line of delegation
What it costs
More classes and files to name, place, and navigate
The client now has to choose and wire a strategy
One extra hop of indirection when tracing a call
Overkill for two stable branches that will never grow
Interview corner
Switch scenarios: a marketplace pricing engine (this one bills in rupees, ₹) has grown a discount function that branches on a type string. Flat off, percentage off, and a tiered rule — with the promise of “buy-one-get-one” and “first-order” landing next sprint.
Refactor this
java
The smell is identical to the payment method one: a single function that must be edited for every new discount, where each branch is a self-contained pricing algorithm. That’s a strategy family in disguise.
Check yourself
References
Official documentation
Java SE — java.util.Comparator and Arrays.sort(T[], Comparator<? super T>).
Spring Security — PasswordEncoder, BCryptPasswordEncoder, and DelegatingPasswordEncoder.
Passport.js — authentication strategies (passportjs.org): each package (passport-local, passport-google-oauth20, passport-jwt) exports a class named Strategy, conventionally aliased on import as LocalStrategy, GoogleStrategy, and JwtStrategy.
Books
Gamma, Helm, Johnson, Vlissides — Design Patterns: Elements of Reusable Object-Oriented Software, the Strategy chapter.
Martin Fowler — Refactoring: Improving the Design of Existing Code, “Replace Conditional with Polymorphism.”
The five creational patterns aren't five clever tricks — they're five answers to one question: how does an object come into existence without the code that uses it hard-wiring the code that makes it? A map, and one interactive way to pick the right one.
A switch(type) buried inside your shared workflow is a design smell. Factory Method moves the 'new' to a subclass so the logic that uses an object never learns which one it got.
One factory hands you a Button, a Checkbox, and a Menu that already agree with each other — so a macOS button can never end up next to a Windows checkbox.
Discussion
Loading the conversation…
Discussion
Loading the conversation…