The Quest Begins (The "Why")
I still remember the first time I opened a legacy service that handled payment processing. It was a monster of a class called PaymentProcessor. Inside, there was a processPayment method that looked like this:
public void processPayment(PaymentRequest req) {
if (req.getType().equals("CREDIT_CARD")) {
// validate card, call gateway A, log, etc.
} else if (req.getType().equals("PAYPAL")) {
// validate email, call gateway B, log, etc.
} else if (req.getType().equals("BITCOIN")) {
// verify wallet, call gateway C, log, etc.
} else {
throw new IllegalArgumentException("Unsupported payment type");
}
}
Every time a new payment method appeared, I had to dive into that method, add another else if, and pray I didn’t break the existing branches. The file grew, the tests became a nightmare, and any tiny change felt like defusing a bomb while blindfolded. I’d spend hours debugging a typo in a nested if and end up questioning my life choices. Honestly, it felt less like coding and more like playing Whack‑a‑Mole with a sledgehammer.
That’s when I realized the real dragon I needed to slay wasn’t the syntax—it was the tight coupling between the decision‑making logic and the concrete algorithms. If I kept going down this road, every feature would become a maintenance nightmare.
The Revelation (The Insight)
The turning point came when a senior teammate slid over a copy of Design Patterns: Elements of Reusable Object‑Oriented Software and pointed to the Strategy Pattern. He said, “Think of it like giving your code a set of interchangeable lightsabers—you pick the one you need for the fight, and the hilt (the context) stays the same.”
The core idea is simple: encapsulate each algorithm in its own object that implements a common interface, and let the context delegate to that object. Instead of a gigantic conditional, you compose behavior at runtime. The benefits?
- Open/Closed Principle – you can add new strategies without touching existing code.
- Single Responsibility – each strategy focuses on one thing.
- Testability – you can swap in a mock strategy and unit‑test the context in isolation.
- Readability – the flow becomes a clear delegation, not a maze of conditionals.
It was like discovering the Force; suddenly, the chaos made sense, and I felt a surge of clarity (and maybe a tiny bit of Jedi‑like confidence).
Wielding the Power (Code & Examples)
The Trap: Monolithic Conditional
Here’s the “before” version again, but with a bit more realism—logging, validation, and a hook for fraud checks:
public class PaymentProcessor {
private final PaymentGateway gatewayA;
private final PaymentGateway gatewayB;
private final PaymentGateway gatewayC;
private final FraudService fraudService;
public PaymentProcessor(PaymentGateway a, PaymentGateway b, PaymentGateway c, FraudService fs) {
this.gatewayA = a;
this.gatewayB = b;
this.gatewayC = c;
this.fraudService = fs;
}
public void processPayment(PaymentRequest req) {
// Validation (shared)
if (req.getAmount() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
// Fraud check (shared)
if (!fraudService.isSafe(req)) {
throw new SecurityException("Potential fraud detected");
}
// Strategy selection – the ugly part
if (req.getType().equals("CREDIT_CARD")) {
gatewayA.charge(req.getCardNumber(), req.getAmount());
} else if (req.getType().equals("PAYPAL")) {
gatewayB.pay(req.getEmail(), req.getAmount());
} else if (req.getType().equals("BITCOIN")) {
gatewayC.send(req.getWalletAddress(), req.getAmount());
} else {
throw new IllegalArgumentException("Unsupported payment type: " + req.getType());
}
// Shared post‑processing
auditLog.log(req);
}
}
What went wrong?
- Adding a new payment method meant editing this class—risking regressions.
- Unit testing
processPaymentrequired mocking all gateways because the method hid which one would be called. - The method violated SRP: it validated, checked fraud, selected a gateway, charged, and logged—all in one place.
The Victory: Strategy Pattern Applied
First, define the strategy interface:
public interface PaymentStrategy {
void pay(PaymentRequest req);
}
Then, create concrete strategies for each payment type:
public class CreditCardStrategy implements PaymentStrategy {
private final PaymentGateway gateway;
private final FraudService fraudService;
public CreditCardStrategy(PaymentGateway gateway, FraudService fraudService) {
this.gateway = gateway;
this.fraudService = fraudService;
}
@Override
public void pay(PaymentRequest req) {
if (!fraudService.isSafe(req)) {
throw new SecurityException("Potential fraud detected");
}
gateway.charge(req.getCardNumber(), req.getAmount());
}
}
public class PayPalStrategy implements PaymentStrategy {
private final PaymentGateway gateway;
private final FraudService fraudService;
public PayPalStrategy(PaymentGateway gateway, FraudService fraudService) {
this.gateway = gateway;
this.fraudService = fraudService;
}
@Override
public void pay(PaymentRequest req) {
if (!fraudService.isSafe(req)) {
throw new SecurityException("Potential fraud detected");
}
gateway.pay(req.getEmail(), req.getAmount());
}
}
// BitcoinStrategy follows the same pattern…
Now the context (PaymentProcessor) becomes a thin delegate:
public class PaymentProcessor {
private final Map<String, PaymentStrategy> strategies;
private final AuditLog auditLog;
public PaymentProcessor(Map<String, PaymentStrategy> strategies, AuditLog auditLog) {
this.strategies = Objects.requireNonNull(strategies);
this.auditLog = auditLog;
}
public void processPayment(PaymentRequest req) {
if (req.getAmount() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
PaymentStrategy strategy = strategies.get(req.getType());
if (strategy == null) {
throw new IllegalArgumentException("Unsupported payment type: " + req.getType());
}
strategy.pay(req); // delegation – the “lightsaber swing”
auditLog.log(req); // shared post‑processing
}
}
How we wire it up (Spring‑style example):
@Configuration
public class PaymentConfig {
@Bean
public PaymentStrategy creditCardStrategy(PaymentGateway gatewayA, FraudService fs) {
return new CreditCardStrategy(gatewayA, fs);
}
@Bean
public PaymentStrategy paypalStrategy(PaymentGateway gatewayB, FraudService fs) {
return new PayPalStrategy(gatewayB, fs);
}
@Bean
public PaymentStrategy bitcoinStrategy(PaymentGateway gatewayC, FraudService fs) {
return new BitcoinStrategy(gatewayC, fs);
}
@Bean
public PaymentProcessor paymentProcessor(
Map<String, PaymentStrategy> strategies,
AuditLog auditLog) {
return new PaymentProcessor(strategies, auditLog);
}
}
Why this feels like a win:
- Adding a new payment method? Just write a new
PaymentStrategybean and add it to the map—no touchingPaymentProcessor. - Unit testing
PaymentProcessoris trivial: inject a mock strategy and verify thatpaywas called once. - Each strategy can evolve independently (different validation, logging, async calls) without affecting others.
- The code reads like a story: “Get the strategy, let it do its job, then log.” No more mental gymnastics to track which branch we’re in.
Why This New Power Matters
Adopting the Strategy Pattern didn’t just clean up a single class—it shifted my mindset. I started seeing behavior as something you compose, not something you hard‑wire. This opened the door to other patterns:
- Factory for creating strategies based on configuration.
- Decorator to add cross‑cutting concerns (like logging or retries) without bloating the strategy itself.
- Dependency Injection frameworks make wiring these pieces almost magical.
The real payoff? Velocity. Features that once took days of careful refactoring now ship in hours. Bugs dropped because each piece is isolated and testable. And the best part? The code feels intentional—you can glance at a strategy and instantly know its purpose, just like recognizing a Jedi’s lightsaber color at a glance.
If you’ve ever stared at a sprawling conditional and felt that familiar dread, give the Strategy Pattern a shot. Wrap each algorithm, inject it, and let your context do the simple delegation. You’ll be amazed at how quickly the chaos turns into clarity.
Your turn: Find a place in your codebase where a giant if/else or switch is deciding which algorithm to run. Extract those branches into strategy objects, wire them up with a map or a factory, and watch the simplicity return. How did it feel when the conditional vanished? Share your story in the comments—I’d love to hear your quest! 🚀
Top comments (0)