How one simple design pattern can save your codebase from turning into an unmaintainable mess of conditionals
Imagine your application supports five payment methods today — credit card, debit card, UPI, PayPal, and net banking. Your processPayment() method has five if-else branches, and life is good. Then marketing decides to add Google Pay. Then Apple Pay. Then "Buy Now, Pay Later." Then a crypto wallet, because why not.
Six months later, your once-tidy PaymentService class is a 400-line monster with nested conditionals, duplicated validation logic, and a comment at the top that says // DO NOT TOUCH UNLESS YOU HATE YOURSELF. Every new payment method means editing this same file, re-testing every existing branch, and praying you didn't break UPI while adding crypto support.
If this sounds familiar, you've just met the exact problem the Strategy Design Pattern was built to solve.
Introduction: Why Design Patterns Exist
Design patterns aren't academic trivia you memorize for interviews and forget the next day. They're battle-tested solutions to problems that every developer eventually runs into, documented so we don't all have to rediscover them the hard way.
The most common beginner mistake with design patterns isn't misusing them — it's not knowing when a problem calls for one in the first place. New developers tend to solve "add new behavior" problems by adding new if branches. It works, right up until it doesn't. The codebase becomes rigid, testing becomes painful, and every change carries the risk of breaking something unrelated.
The Strategy Pattern exists specifically for this situation: when you have a family of related algorithms or behaviors, and you need to switch between them cleanly, without a wall of conditionals standing in your way.
In one sentence: Strategy Pattern lets you define a family of interchangeable algorithms, encapsulate each one, and swap them at runtime — without touching the code that uses them.
What You'll Learn
By the end of this article, you'll understand:
- What the Strategy Pattern is and the problem it solves
- Why long
if-elsechains are a code smell, not just an inconvenience - A complete, working Java implementation from scratch
- How the pattern naturally satisfies key SOLID principles
- Real-world systems that use this pattern every day
- When Strategy Pattern is the wrong choice
- Strategy vs. plain
if-elsevs. Factory Pattern - Common beginner mistakes and how to avoid them
- Interview questions you're likely to be asked about it
Let's dig in.
The Problem: An E-Commerce Payment System
Let's say you're building the checkout flow for an e-commerce platform. At launch, you support two payment methods. Simple enough — a single method with an if-else block gets the job done.
Fast forward a year. Your product now supports credit cards, UPI, PayPal, and it's growing. Here's what that code usually starts to look like.
Bad Code Example
public class PaymentService {
public void processPayment(String paymentType, double amount) {
if (paymentType.equalsIgnoreCase("CREDIT_CARD")) {
System.out.println("Validating credit card details...");
System.out.println("Charging ₹" + amount + " to credit card.");
System.out.println("Sending confirmation email for credit card payment.");
} else if (paymentType.equalsIgnoreCase("UPI")) {
System.out.println("Validating UPI ID...");
System.out.println("Charging ₹" + amount + " via UPI.");
System.out.println("Sending UPI payment confirmation SMS.");
} else if (paymentType.equalsIgnoreCase("PAYPAL")) {
System.out.println("Redirecting to PayPal login...");
System.out.println("Charging ₹" + amount + " via PayPal.");
System.out.println("Sending PayPal receipt.");
} else {
throw new IllegalArgumentException("Unsupported payment type: " + paymentType);
}
}
}
At first glance, this doesn't look terrible. But let's break down exactly why this pattern becomes dangerous as your application grows.
Why This Code Is a Problem
1. Tight Coupling
PaymentService knows the implementation details of every single payment method. It shouldn't have to know how PayPal authentication works or how UPI validation happens — it should just know "process the payment."
2. Violates the Open/Closed Principle
The O in SOLID states that classes should be open for extension but closed for modification. Every time you add a new payment method, you're forced to modify this existing, already-tested class. That's a violation by definition.
3. Difficult to Test
Unit testing this class means testing every branch every time, because they all live in the same method. You can't test the PayPal logic in isolation from the credit card logic.
4. Difficult to Extend
Adding "Google Pay" means opening this file, finding the right spot, adding another else if, and hoping you don't introduce a typo that silently breaks another branch.
5. Poor Readability at Scale
Ten payment methods later, this method is unreadable. Nobody wants to scroll through 300 lines of nested conditionals to understand what happens when a user pays via net banking.
Note: This isn't unique to payments. The same problem shows up in shipping calculators, tax engines, discount logic, notification systems, authentication flows — anywhere you have "one action, many possible behaviors."
Introducing the Strategy Pattern
The Strategy Pattern is a behavioral design pattern that lets you define a family of algorithms, put each one in its own class, and make them interchangeable at runtime through a common interface.
Instead of asking "which if branch handles this?", your code asks "which strategy object handles this?" — and calls the exact same method on it, regardless of which one it is.
The core idea in plain English:
Separate what varies (the algorithm/behavior) from what stays the same (the code that uses it).
The class that uses the strategy (in our case, ShoppingCart) doesn't need to know which concrete implementation it's using. It just needs to know that whatever it's holding can pay().
UML Diagram
┌────────────────────┐
│ PaymentStrategy │ <<interface>>
├────────────────────┤
│ + pay(amount: double)│
└──────────▲──────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ CreditCardPayment│ │ UPIPayment │ │ PayPalPayment │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ + pay(amount) │ │ + pay(amount) │ │ + pay(amount) │
└───────────────┘ └───────────────┘ └───────────────┘
┌────────────────────┐
│ ShoppingCart │
├────────────────────┤
│ - strategy: PaymentStrategy │
│ + setStrategy(s) │
│ + checkout(amount) │
└────────────────────┘
ShoppingCart holds a reference to a PaymentStrategy — never a concrete class. That reference is what makes swapping behavior at runtime possible.
Step-by-Step Java Implementation
Step 1: Define the Strategy Interface
public interface PaymentStrategy {
void pay(double amount);
}
This interface is the contract. Every payment method must implement pay(). ShoppingCart will only ever talk to this interface — never to a specific class.
Step 2: Implement Concrete Strategies
public class CreditCardPayment implements PaymentStrategy {
private final String cardNumber;
public CreditCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public void pay(double amount) {
System.out.println("Validating credit card ending in "
+ cardNumber.substring(cardNumber.length() - 4));
System.out.println("Charged ₹" + amount + " to credit card.");
}
}
public class UPIPayment implements PaymentStrategy {
private final String upiId;
public UPIPayment(String upiId) {
this.upiId = upiId;
}
@Override
public void pay(double amount) {
System.out.println("Validating UPI ID: " + upiId);
System.out.println("Charged ₹" + amount + " via UPI.");
}
}
public class PayPalPayment implements PaymentStrategy {
private final String email;
public PayPalPayment(String email) {
this.email = email;
}
@Override
public void pay(double amount) {
System.out.println("Redirecting to PayPal account: " + email);
System.out.println("Charged ₹" + amount + " via PayPal.");
}
}
Each class owns only its own logic. No class knows the others exist.
Step 3: The Context Class — ShoppingCart
public class ShoppingCart {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
public void checkout(double amount) {
if (paymentStrategy == null) {
throw new IllegalStateException("Payment strategy not set!");
}
paymentStrategy.pay(amount);
}
}
This is called the Context. It doesn't know or care how payment happens — it simply delegates to whatever PaymentStrategy it currently holds.
Step 4: Put It All Together
public class Main {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
// Pay using Credit Card
cart.setPaymentStrategy(new CreditCardPayment("4111111111111234"));
cart.checkout(2500.00);
System.out.println();
// Pay using UPI
cart.setPaymentStrategy(new UPIPayment("ansi@upi"));
cart.checkout(799.00);
System.out.println();
// Pay using PayPal
cart.setPaymentStrategy(new PayPalPayment("ansi@example.com"));
cart.checkout(1599.00);
}
}
Execution Flow
Here's what actually happens internally when checkout() is called:
Main
│
▼
ShoppingCart.checkout(amount)
│
▼
paymentStrategy.pay(amount) ← polymorphic call
│
▼
Actual object determines behavior:
CreditCardPayment.pay() OR UPIPayment.pay() OR PayPalPayment.pay()
ShoppingCart calls pay() on an interface reference. Java's dynamic dispatch figures out at runtime which actual class's method to run, based on the object that was assigned via setPaymentStrategy(). ShoppingCart itself never branches on payment type — that logic simply doesn't exist there anymore.
Output
Validating credit card ending in 1234
Charged ₹2500.0 to credit card.
Validating UPI ID: ansi@upi
Charged ₹799.0 via UPI.
Redirecting to PayPal account: ansi@example.com
Charged ₹1599.0 via PayPal.
Adding a New Payment Method
Let's say the product team now wants Google Pay support. Here's the entire change required:
public class GooglePayPayment implements PaymentStrategy {
private final String phoneNumber;
public GooglePayPayment(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
public void pay(double amount) {
System.out.println("Verifying Google Pay account: " + phoneNumber);
System.out.println("Charged ₹" + amount + " via Google Pay.");
}
}
cart.setPaymentStrategy(new GooglePayPayment("+91-9876543210"));
cart.checkout(999.00);
Notice what we didn't touch:
-
PaymentStrategyinterface — untouched -
ShoppingCart— untouched -
CreditCardPayment,UPIPayment,PayPalPayment— untouched
We only added a new class. Nothing existing was modified, nothing existing needs to be re-tested. That's the entire point of the Open/Closed Principle in action.
How Strategy Pattern Follows SOLID Principles
Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
We just proved this directly — GooglePayPayment extended the system's behavior without modifying a single existing line.
Dependency Inversion Principle (DIP)
"Depend on abstractions, not concretions."
ShoppingCart depends on the PaymentStrategy interface, not on CreditCardPayment or UPIPayment directly. High-level modules (ShoppingCart) and low-level modules (payment implementations) both depend on the abstraction, not on each other.
Interface Segregation Principle (ISP)
PaymentStrategy exposes exactly one method: pay(). No implementing class is forced to implement methods it doesn't need — a common problem with bloated "do everything" interfaces. Because the interface is small and focused, ISP is naturally satisfied.
Real-World Examples
| System | How Strategy Pattern Applies |
|---|---|
| Google Maps | Switches between driving, walking, cycling, and transit route-calculation strategies |
| Netflix | Chooses video quality/encoding strategy based on bandwidth and device |
| Payment Gateways | Stripe, Razorpay, and similar SDKs swap payment processor logic behind a common interface |
| File Compression Tools | ZIP, RAR, and GZIP compression algorithms are interchangeable strategies |
| Authentication Providers | Swap between OAuth, SAML, or username/password login strategies |
| Notification Systems | Send via Email, SMS, or Push Notification using the same notify() contract |
| Sorting Algorithms | Java's Collections.sort() accepts a Comparator — a textbook Strategy Pattern |
| AI Model Selection | Systems that route requests to different models (fast vs. accurate) based on context |
| Shipping Providers | E-commerce platforms switch between FedEx, DHL, or local courier calculation strategies |
If you've ever passed a Comparator into Collections.sort(), you've already used the Strategy Pattern — even if nobody called it that at the time.
Advantages
| Advantage | Explanation |
|---|---|
| Open/Closed Compliant | Add new behavior without modifying existing code |
| Improved Testability | Each strategy can be unit tested in complete isolation |
| Eliminates Conditional Complexity | No more sprawling if-else or switch chains |
| Runtime Flexibility | Behavior can be swapped dynamically, even mid-execution |
| Better Separation of Concerns | Each class has exactly one responsibility |
| Reusable Algorithms | Strategies can be reused across different contexts |
Disadvantages
The Strategy Pattern isn't free — it comes with trade-offs worth being honest about:
- Increased number of classes. Every strategy is its own class. For a system with only two or three simple, stable behaviors, this can be overkill.
- Client must be aware of strategies. Something, somewhere, has to decide which strategy to instantiate — that decision logic doesn't disappear, it just moves.
-
Overhead for simple cases. If your "algorithm" is a single
if-elsewith two branches that will never grow, introducing an interface and two classes may be unnecessary ceremony.
When NOT to use Strategy Pattern: If the behavior is genuinely fixed, rarely changes, and has only two simple outcomes, a plain conditional is often more readable than a full pattern implementation. Don't reach for a hammer when a screwdriver will do.
Strategy Pattern vs. Simple if-else
| Aspect | if-else Chain | Strategy Pattern |
|---|---|---|
| Adding new behavior | Requires modifying existing method | Add a new class only |
| Testability | Hard to isolate individual branches | Each strategy tested independently |
| Readability at scale | Degrades rapidly | Stays clean regardless of scale |
| Coupling | Tight coupling to all implementations | Loose coupling via interface |
| Runtime flexibility | Static, hardcoded | Dynamic, swappable at runtime |
| Best suited for | 2–3 branches that rarely change | Growing or evolving behavior sets |
Strategy Pattern vs. Factory Pattern
These two get confused often, but they solve different problems:
- Factory Pattern answers: "How do I create an object?" It's about object creation — hiding the instantiation logic and returning the right object based on input.
- Strategy Pattern answers: "How do I choose which behavior to execute?" It's about behavior selection, assuming you already have the object.
In practice, they're often used together — a Factory creates the right PaymentStrategy object, and the Strategy Pattern then determines how checkout() behaves once that object is injected into ShoppingCart.
public class PaymentStrategyFactory {
public static PaymentStrategy getStrategy(String type) {
return switch (type) {
case "CREDIT_CARD" -> new CreditCardPayment("4111111111111234");
case "UPI" -> new UPIPayment("ansi@upi");
case "PAYPAL" -> new PayPalPayment("ansi@example.com");
default -> throw new IllegalArgumentException("Unknown type: " + type);
};
}
}
Notice the factory still has a conditional — but it's isolated to one job: creation. It never leaks into business logic, and ShoppingCart remains completely unaware of it.
Common Mistakes Beginners Make
Forgetting the interface entirely. Some beginners create multiple strategy classes but skip the shared interface, defeating the entire purpose — the context class ends up doing type-checking again.
Overengineering trivial logic. Wrapping a single, permanent, two-branch decision in a full Strategy Pattern setup adds unnecessary indirection for zero benefit.
Creating unnecessary strategies. Not every varying value needs its own class. If the "algorithm" is really just a different configuration value (like a tax rate), a strategy class is overkill — a simple parameter will do.
Putting business logic in the context class. The context (
ShoppingCart) should delegate, not decide. If you find yourself addingifstatements back into the context "just this once," you've undone the pattern's benefit.
Best Practices
Tip: Keep your strategy interface as small as possible — ideally one method. This keeps it flexible and easy to implement.
- Favor composition over inheritance — inject strategies rather than subclassing.
- Combine with a Factory when strategy selection logic itself grows complex.
- Use dependency injection frameworks (like Spring) to wire strategies automatically instead of manual
newcalls. - Name strategy classes after what they do, not generic names like
Strategy1,Strategy2. - Keep the context class ignorant of concrete strategy implementations — no casting, no
instanceofchecks. - Write unit tests per strategy, not one giant test covering all branches.
Key Takeaways
- Long
if-elsechains for selecting behavior are a code smell that gets worse as your application grows. - The Strategy Pattern separates what varies (algorithms) from what stays constant (the code using them).
- It's built around a simple interface, multiple concrete implementations, and a context class that delegates.
- It directly supports the Open/Closed, Dependency Inversion, and Interface Segregation principles.
- It's used constantly in real systems — Google Maps, Netflix, payment gateways, and even Java's own
Comparator. - It's not a silver bullet — for genuinely simple, stable logic, a plain conditional can be the better choice.
Conclusion
Every experienced developer has, at some point, inherited a method with a dozen if-else branches and thought, "there has to be a better way." The Strategy Pattern is that better way — not because it's clever, but because it's honest about what changes and what doesn't.
The next time you catch yourself adding "just one more else if" to a method that already has five, stop. Ask yourself whether you're really writing conditionals, or whether you're describing a family of interchangeable behaviors that deserve their own home.
Start small. Pick one method in your current project with more than three branches selecting behavior, and refactor it using Strategy Pattern this week. You'll feel the difference the next time a new requirement lands on your desk — and it doesn't make you touch code you already trusted.
Clean architecture isn't built in a day. It's built one refactor at a time.
Top comments (0)