Strategy Pattern: Stop Writing if-else Chains

Design Patterns Series #1 – Behavioral Patterns

It always starts the same way. A new feature request lands in your inbox: “We need to add Google Pay.” You open the payment processing class, scroll through the if-else chain that already handles Stripe, PayPal, and BLIK, and feel that familiar anxiety – because touching this method means you could break everything else that already works.


You’ve just stumbled into one of the most common traps in object-oriented design: a class that knows too much, does too much, and changes for too many reasons. The Strategy pattern is the way out.

What Is the Strategy Pattern?

Strategy is a behavioral design pattern that lets you define a family of algorithms, put each one in a separate class, and make them interchangeable at runtime. The key insight is simple: instead of hardcoding logic into a class, you inject it from outside.

Three components make up the pattern:

  • Strategy interface – the contract. Defines what every algorithm must be able to do, without caring how.
  • Concrete strategies – the implementations. Each one fulfills the contract in its own way.
  • Context – the caller. Holds a reference to whichever strategy is active and delegates work to it.

The context doesn’t know – and doesn’t need to know – which concrete strategy it’s talking to. It just calls the interface.


The Problem in Code

Let’s make the pain concrete. Here’s a typical payment processor before the pattern:

// PaymentProcessor.java - before Strategy
public class PaymentProcessor {

    public void processPayment(String method, double amount) {
        if (method.equals("STRIPE")) {
            stripeClient.charge(amount, "USD");
            stripeClient.confirm();
            log.info("Stripe payment confirmed.");

        } else if (method.equals("PAYPAL")) {
            paypalGateway.createOrder(amount);
            paypalGateway.capture();
            log.info("PayPal payment captured.");

        } else if (method.equals("BLIK")) {
            blikService.initTransaction(amount);
            blikService.verify();
            log.info("BLIK transaction verified.");
        }
        // Google Pay? Add another else-if. Touch this method. Pray.
    }
}

Every new payment provider forces you to modify this class. Every modification risks a regression. Every test has to account for all branches together. This is exactly what the Open/Closed Principle warns against: a class that is never closed for modification.


The Solution, Step by Step

1. Define the strategy interface

Start by identifying what all payment methods have in common. They all do one thing: process a payment for a given amount. That becomes the contract.

// PaymentStrategy.java
public interface PaymentStrategy {
    void pay(double amount);
}

2. Implement a concrete strategy for each provider

Each payment provider gets its own class. The Stripe logic lives only in StripePayment. The BLIK logic lives only in BlikPayment. They are completely isolated from each other.

// StripePayment.java
public class StripePayment implements PaymentStrategy {
    private final StripeClient stripeClient;

    public StripePayment(StripeClient stripeClient) {
        this.stripeClient = stripeClient;
    }

    @Override
    public void pay(double amount) {
        stripeClient.charge(amount, "USD");
        stripeClient.confirm();
        log.info("Paid {} via Stripe.", amount);
    }
}
// PayPalPayment.java
public class PayPalPayment implements PaymentStrategy {
    private final PayPalGateway gateway;

    public PayPalPayment(PayPalGateway gateway) {
        this.gateway = gateway;
    }

    @Override
    public void pay(double amount) {
        gateway.createOrder(amount);
        gateway.capture();
        log.info("Paid {} via PayPal.", amount);
    }
}
// BlikPayment.java
public class BlikPayment implements PaymentStrategy {
    private final BlikService blikService;

    public BlikPayment(BlikService blikService) {
        this.blikService = blikService;
    }

    @Override
    public void pay(double amount) {
        blikService.initTransaction(amount);
        blikService.verify();
        log.info("Paid {} via BLIK.", amount);
    }
}

3. Build the context

The context is the glue. It holds the active strategy and delegates the payment call to it. Notice it has zero knowledge of Stripe, PayPal, or BLIK.

// PaymentContext.java
public class PaymentContext {
    private PaymentStrategy strategy;

    public PaymentContext(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

    // Strategy can be swapped at runtime - user changes their mind at checkout
    public void setStrategy(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

    public void executePayment(double amount) {
        strategy.pay(amount);
    }
}

4. Wire it together

In the real world, the strategy would typically be resolved from the user’s selection and injected via a factory or a DI container. Here’s a simplified view of how the caller looks:

// Checkout.java - clean caller, no branching
public class Checkout {
    public static void main(String[] args) {

        // Strategies are plain objects - easy to construct, easy to mock
        PaymentStrategy stripe = new StripePayment(new StripeClient());
        PaymentStrategy blik   = new BlikPayment(new BlikService());

        PaymentContext context = new PaymentContext(stripe);
        context.executePayment(99.99);
        // > Paid 99.99 via Stripe.

        // User switches provider at checkout - no branching, no rewrite
        context.setStrategy(blik);
        context.executePayment(49.00);
        // > Paid 49.00 via BLIK.

        // Google Pay next sprint? New class. Zero existing code touched.
    }
}

Why This Actually Works

Open/Closed Principle in practice. Adding Google Pay means writing a new class – GooglePayPayment – and nothing else. The context, the existing strategies, the checkout logic: all untouched. That’s the whole point of OCP. Your system grows by addition, not mutation.

Each strategy is independently testable. Before the pattern, testing the Stripe path meant constructing the entire PaymentProcessor with all its dependencies. Now you test StripePayment in isolation, mock StripeClient, and you’re done in ten lines. No risk of BLIK logic interfering.


When to Use It – and When Not To

Reach for Strategy when:

  • You have multiple variants of behaviour that need to be swappable at runtime.
  • An if-else or switch block is growing and you keep touching the same class.
  • You want to be able to test each variant in isolation.

Leave it on the shelf when:

  • You have exactly one algorithm today with no realistic plan to add more.
  • The variation is trivial – a boolean flag or a config value is enough.
  • You’re introducing abstraction before the problem actually exists (YAGNI).

A pattern is a tool, not a trophy. Using Strategy where a simple if would do is overengineering, and overengineering is its own kind of mess.


Wrapping Up

The Strategy pattern doesn’t remove complexity – it moves it to where it belongs. Each payment provider is a self-contained unit. The context is clean. The caller doesn’t branch. And the next time a product manager asks for “just one more payment option,” you write a class, not a prayer.

Next up in this series: Factory Method – how to create objects without hardcoding which class to instantiate, and why that matters more than it sounds.


Have you seen Strategy in the wild? Drop a comment below – I’m curious what problems you’ve solved with it, or what you tried before you found a cleaner way. If you spotted something I could explain better, I want to hear that too.

Posted in Design-PatternsTags:
Write a comment