The Strategy Pattern in Java — Replacing Conditional Dispatch With Polymorphism

by Eric Hanson, Backend Developer at Clean Systems Consulting

The problem: behavior that varies by type

A payment processor that handles multiple payment methods starts simple:

public class PaymentProcessor {
    public void process(Payment payment) {
        if (payment.getMethod() == PaymentMethod.CREDIT_CARD) {
            chargeCreditCard(payment);
        } else if (payment.getMethod() == PaymentMethod.BANK_TRANSFER) {
            initiateBankTransfer(payment);
        } else if (payment.getMethod() == PaymentMethod.CRYPTO) {
            broadcastCryptoTransaction(payment);
        }
    }

    private void chargeCreditCard(Payment payment) { ... }
    private void initiateBankTransfer(Payment payment) { ... }
    private void broadcastCryptoTransaction(Payment payment) { ... }
}

This works for three payment methods. The problems compound as the codebase grows:

Every new payment method requires modifying PaymentProcessor. Every place in the codebase that branches on PaymentMethod needs updating. The class accumulates methods that aren't related to each other — credit card logic lives alongside crypto logic. Testing any one path requires loading the full class.

The if-else chain is a symptom: behavior that varies by type is scattered across a class that shouldn't know about the variation details.

The strategy pattern: one interface, many implementations

Extract the varying behavior behind an interface:

public interface PaymentStrategy {
    void process(Payment payment);
    boolean supports(PaymentMethod method);
}

Each payment method is one implementation:

public class CreditCardStrategy implements PaymentStrategy {
    private final CreditCardGateway gateway;

    public CreditCardStrategy(CreditCardGateway gateway) {
        this.gateway = gateway;
    }

    @Override
    public void process(Payment payment) {
        gateway.charge(payment.getAmount(), payment.getCardDetails());
    }

    @Override
    public boolean supports(PaymentMethod method) {
        return method == PaymentMethod.CREDIT_CARD;
    }
}

public class BankTransferStrategy implements PaymentStrategy {
    @Override
    public void process(Payment payment) { ... }

    @Override
    public boolean supports(PaymentMethod method) {
        return method == PaymentMethod.BANK_TRANSFER;
    }
}

The PaymentProcessor becomes a dispatcher that selects and delegates:

public class PaymentProcessor {
    private final List<PaymentStrategy> strategies;

    public PaymentProcessor(List<PaymentStrategy> strategies) {
        this.strategies = List.copyOf(strategies);
    }

    public void process(Payment payment) {
        PaymentStrategy strategy = strategies.stream()
            .filter(s -> s.supports(payment.getMethod()))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentMethodException(payment.getMethod()));

        strategy.process(payment);
    }
}

Adding a new payment method is a new class that implements PaymentStrategy. PaymentProcessor doesn't change. Each strategy is independently testable. The class that knew about all payment methods now knows about none of them.

The map dispatch table — a lighter-weight alternative

When the strategy selection is by a known key (an enum, a string code, an integer type) and the strategies themselves are stateless or easily expressed as lambdas, a Map dispatch table is simpler than the full interface pattern:

public class PaymentProcessor {
    private final Map<PaymentMethod, Consumer<Payment>> handlers;

    public PaymentProcessor(
            CreditCardGateway creditCardGateway,
            BankTransferService bankTransferService) {

        this.handlers = Map.of(
            PaymentMethod.CREDIT_CARD,    p -> creditCardGateway.charge(p.getAmount(), p.getCardDetails()),
            PaymentMethod.BANK_TRANSFER,  p -> bankTransferService.initiate(p.getBankDetails(), p.getAmount()),
            PaymentMethod.CRYPTO,         p -> broadcastCryptoTx(p)
        );
    }

    public void process(Payment payment) {
        Consumer<Payment> handler = handlers.get(payment.getMethod());
        if (handler == null) {
            throw new UnsupportedPaymentMethodException(payment.getMethod());
        }
        handler.accept(payment);
    }
}

The dispatch table is: a Map keyed by the discriminator value, values are lambdas or method references for the behavior. Lookup is O(1) instead of O(n) iteration over strategies. The wiring is in the constructor, visible in one place.

When to use the map table over the full strategy interface:

  • The strategies are stateless or share injected state from the enclosing class
  • The behavior is simple enough to fit in a lambda
  • The discriminator is a simple key (enum, string) with no complex matching

When to use the full interface:

  • Strategies have their own state and dependencies
  • Strategy selection requires complex matching (not just key equality)
  • Strategies need to be dynamically registered or loaded
  • Each strategy is independently deployed or tested at scale

Enums as strategy containers

When the set of strategies is fixed and known at compile time, enum abstract methods carry behavior directly on the constant:

public enum ShippingStrategy {
    STANDARD {
        @Override
        public ShippingCost calculate(Order order) {
            return ShippingCost.of(order.getWeight() * 0.5, Currency.USD);
        }

        @Override
        public Duration estimatedDelivery() {
            return Duration.ofDays(5);
        }
    },
    EXPRESS {
        @Override
        public ShippingCost calculate(Order order) {
            return ShippingCost.of(order.getWeight() * 1.5 + 5.00, Currency.USD);
        }

        @Override
        public Duration estimatedDelivery() {
            return Duration.ofDays(1);
        }
    },
    OVERNIGHT {
        @Override
        public ShippingCost calculate(Order order) {
            return ShippingCost.of(order.getWeight() * 3.0 + 20.00, Currency.USD);
        }

        @Override
        public Duration estimatedDelivery() {
            return Duration.ofHours(12);
        }
    };

    public abstract ShippingCost calculate(Order order);
    public abstract Duration estimatedDelivery();
}

The caller:

ShippingCost cost = order.getShippingStrategy().calculate(order);
Duration eta      = order.getShippingStrategy().estimatedDelivery();

No switch, no if-else, no dispatch needed. The strategy is the enum constant. Adding a new strategy requires adding a new constant with its implementations — the compiler enforces that all abstract methods are implemented.

This is the most concise form for fixed strategy sets. It's also the least flexible — strategies must be defined at compile time and cannot have independent state beyond what the enum carries.

Strategy with context — passing what the strategy needs

A strategy interface that takes only the entity being processed works for simple cases. When strategies need access to services or configuration that vary per strategy, two options:

Constructor injection on the strategy implementation:

public class CreditCardStrategy implements PaymentStrategy {
    private final CreditCardGateway gateway;
    private final FraudDetector fraudDetector;

    public CreditCardStrategy(CreditCardGateway gateway, FraudDetector fraudDetector) {
        this.gateway = gateway;
        this.fraudDetector = fraudDetector;
    }
}

Each strategy is a full class with its own dependencies. Wired at the composition root. The PaymentProcessor doesn't know what dependencies each strategy needs.

A context object passed to process():

public interface PaymentStrategy {
    void process(Payment payment, PaymentContext context);
}

public record PaymentContext(
    FraudDetector fraudDetector,
    AuditLog auditLog,
    NotificationService notifications
) {}

The context carries shared services all strategies might use. Individual strategies use what they need and ignore the rest. This avoids the composition root having to wire each strategy individually, at the cost of each strategy receiving services it may not use.

When the if-else is fine

The strategy pattern adds indirection. Indirection has a cost — more files, more interfaces, more places to look when tracing execution. It's worth paying when:

  • The number of variants is large or growing
  • Adding a new variant today requires modifying existing code
  • Each variant is complex enough to warrant its own class and tests
  • The variants are used in multiple places that would all need updating

It's not worth paying when:

  • There are two or three stable variants that haven't changed in months
  • Each variant is a single expression or a few lines
  • The branching is only in one place
// This is fine — two stable cases, one line each, used nowhere else
public String formatAmount(Payment payment) {
    return payment.getCurrency() == Currency.USD
        ? "$" + payment.getAmount()
        : payment.getAmount() + " " + payment.getCurrency().getCode();
}

A strategy interface for two formatting cases is over-engineering. The if-else is readable, the intent is clear, and there's nothing to gain from the extraction.

The signal to refactor: the same switch statement appears in multiple places and you find yourself updating all of them when a new case is added. That's the point where extracting strategy implementations pays for itself.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Hiring Backend Developers in New York Takes 11 Weeks. Here Is What Smart Founders Do Instead

You posted the role eight weeks ago. You've done six technical screens. Your top candidate just accepted an offer somewhere else.

Read more

When Your API Integration Explodes in Production

Everything worked fine in testing. Then production hits—and suddenly your API integration turns into a disaster you didn’t see coming.

Read more

Feeling Underqualified? How to Fake Confidence (Safely)

Everyone feels underqualified sometimes, especially early in their career. Here’s how to appear confident without pretending to be an expert you’re not.

Read more

Negotiating Contracts Without Feeling Awkward

Talking money doesn’t have to feel like a root canal. Negotiating contracts can be professional, clear, and even comfortable.

Read more