DEV Community

Timevolt
Timevolt

Posted on

Level Up Your Code: How the Strategy Pattern Made Me Feel Like a Jedi

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

What went wrong?

  • Adding a new payment method meant editing this class—risking regressions.
  • Unit testing processPayment required 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);
}
Enter fullscreen mode Exit fullscreen mode

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…
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this feels like a win:

  • Adding a new payment method? Just write a new PaymentStrategy bean and add it to the map—no touching PaymentProcessor.
  • Unit testing PaymentProcessor is trivial: inject a mock strategy and verify that pay was 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)