Picture this scenario.
You are shipping a feature for your e-commerce app. Users can now pay via credit card, UPI, or net banking.
So you write it:
public void processPayment(String type, double amount) { if (type.equals("CREDIT_CARD")) { // validate card, call Stripe, log transaction... } else if (type.equals("UPI")) { // validate VPA, call UPI gateway... } else if (type.equals("NET_BANKING")) { // bank list, redirect, callback... } // next quarter: add PayPal -> edit this file again // then crypto -> edit this file again }
It ships. Life is good — for three months. Then the product team adds two more payment methods. Then a third. The method balloons to 285 lines. Nobody wants to touch it anymore. One wrong keystroke in the UPI block can silently break the credit card flow. Your unit tests are a nightmare.
if (type == "CREDIT_CARD") { ... 40 lines ... } else if (type == "UPI") { ... 35 lines ... } else if (type == "NET_BANKING") { ... 50 lines ... } else if (type == "PAYPAL") { ... 45 lines ... } ← added Q2 else if (type == "CRYPTO") { ... 60 lines ... } ← added Q3 else if (type == "BNPL") { ... 55 lines ... } ← added Q4
Touch one branch, pray the others survive.
— Gang of Four, Design Patterns (1994)
Strategy Pattern is not about adding classes. It’s about removing decisions from your code.
If your code keeps asking “what type is this?” at runtime — through instanceof, string comparisons, or enum switches — that’s your codebase screaming for Strategy. Every if (type == X) is a decision that could live inside a class instead.
The Strategy pattern is a behavioural design pattern. That word “behavioural” is key — it is about how objects communicate and how responsibilities are distributed, not about how they are created (Creational) or how they are structured (Structural).
The core idea: pull each variant of an algorithm out of a conditional chain, wrap it in its own class, and plug it in through a shared interface. The caller — called the Context — never needs to know which specific algorithm is running. It just calls execute().
The Strategy pattern is one of the most commonly asked Low-Level Design (LLD) questions at top product companies like Amazon, Flipkart, and Razorpay. If you have an SDE interview in your future, this pattern is not optional.
Before we touch code, let us get the vocabulary right. The Strategy pattern has exactly three moving parts:
Role | Responsibility | In our payment example |
Context | Holds a reference to the active strategy. Delegates execution to it. | PaymentProcessor |
Strategy (interface) | Defines the contract — what every algorithm must be able to do. | PaymentStrategy |
Concrete Strategy | One class per algorithm variant. Implements the interface. | CreditCardStrategy, UpiStrategy |
|
✗ Before Strategy
One bloated method
|
✓ After Strategy
Plug-and-play algorithms
|
|---|---|
| ● All algorithms crammed into if-else | ● Each algorithm = its own class |
| ● One change can break everything | ● Changes are fully isolated |
| ● New payment = edit existing file | ● New payment = add one new file |
| ● Can’t unit test algorithms in isolation | ● Each strategy is independently testable |
| ● Grows without bound — forever | ● Context stays lean — always |
First, we identify the common contract. Every payment method needs to do two things: validate the input and process the payment. That goes into an interface.
PaymentStrategy.java
// The shared contract -- every payment method must implement this public interface PaymentStrategy { boolean validate(PaymentRequest request); PaymentResult pay(double amount, PaymentRequest request); }
Each payment method becomes its own class. It only knows about its own logic. Changes to UPI logic cannot possibly break the credit card class — they live in completely separate files.
CreditCardStrategy.java
public class CreditCardStrategy implements PaymentStrategy { private final StripeGateway stripe; public CreditCardStrategy(StripeGateway stripe) { this.stripe = stripe; } @Override public boolean validate(PaymentRequest req) { // Luhn check, expiry validation, CVV length... return LuhnAlgorithm.check(req.getCardNumber()); } @Override public PaymentResult pay(double amount, PaymentRequest req) { return stripe.charge(amount, req.getToken()); } }
UpiStrategy.java
public class UpiStrategy implements PaymentStrategy { private final NpciGateway npci; @Override public boolean validate(PaymentRequest req) { // VPA format check: user@bank return req.getVpa().matches("^[a-zA-Z0-9.]+@[a-zA-Z]+$"); } @Override public PaymentResult pay(double amount, PaymentRequest req) { return npci.initiateTransfer(req.getVpa(), amount); } }
The Context holds a reference to whichever strategy is active. It delegates the work without caring about the implementation details. This is the entire magic of the pattern.
PaymentProcessor.java (Context)
public class PaymentProcessor { private PaymentStrategy strategy; // set at runtime // Strategy is injected -- not hardcoded public void setStrategy(PaymentStrategy strategy) { this.strategy = strategy; } public PaymentResult processPayment(double amount, PaymentRequest request) { if (!strategy.validate(request)) { throw new InvalidPaymentException("Validation failed"); } return strategy.pay(amount, request); // Works identically for CreditCard, UPI, BNPL, Crypto -- anything } }
At runtime — perhaps in your service layer or a factory — you decide which strategy to use. The PaymentProcessor is blissfully unaware of this decision.
CheckoutService.java
PaymentProcessor processor = new PaymentProcessor(); // User selects UPI at checkout processor.setStrategy(new UpiStrategy(npciGateway)); processor.processPayment(499.00, request); // Corporate user selects credit card -- swap the strategy, same processor processor.setStrategy(new CreditCardStrategy(stripeGateway)); processor.processPayment(14999.00, request); // Tomorrow, product adds crypto payments -- create ONE new class, zero changes here processor.setStrategy(new CryptoStrategy(web3Gateway)); processor.processPayment(0.05, request);
Adding crypto payments required creating exactly one new file — CryptoStrategy.java. Zero edits to PaymentProcessor. Zero risk to existing payment flows. This is the Open/Closed Principle enforced at the architectural level.
In production systems, engineers don’t call setStrategy() manually every time. They build a strategy registry — a map from a key to a strategy — and resolve the right one at runtime. This is how Razorpay, Paytm, and every serious payment system actually looks under the hood:
StrategyRegistry — Real Engineering Thinking
// Register all strategies once -- at startup or via DI framework Map<String, PaymentStrategy> strategies = Map.of( "UPI", new UpiStrategy(npciGateway), "CREDIT_CARD", new CreditCardStrategy(stripeGateway), "NET_BANKING", new NetBankingStrategy(hdfc), "BNPL", new BnplStrategy(simpl), "CRYPTO", new CryptoStrategy(web3Gateway) ); // At checkout -- resolve from user's selection, no if-else anywhere String userChoice = request.getPaymentMethod(); // e.g. "UPI" PaymentStrategy strategy = strategies.get(userChoice); if (strategy == null) { throw new UnsupportedPaymentException("Unknown method: " + userChoice); } PaymentResult result = strategy.pay(order.getTotal(), request); // No switch. No if-else. Just a map lookup. Clean, scalable, O(1).
The registry pattern is Strategy + a factory built into a Map. Adding a new payment method in production literally means adding one line to this map and one new class file. No existing logic is touched. Zero regression risk. This is the pattern inside Spring’s HandlerMapping, Kafka’s deserializer registry, and most plugin architectures.
Already have a giant if-else in your codebase? Here’s exactly how to migrate it. This is the journey every engineer who has truly internalized Strategy goes through. And you can start it on your very next sprint.