The Strategy Pattern in Java — Replacing Conditional Dispatch With Polymorphism

by Arif Ikhsanudin, Backend Developer

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

Spring Data Repository Design — When findBy Methods Are Enough and When They're Not

Spring Data's derived query methods eliminate boilerplate for simple queries. They become unreadable for complex ones and break entirely for dynamic filtering. Here is where each approach belongs and how to recognize when you've outgrown derived queries.

Read more

Recovering From a Public Mistake (Like a Website Crash)

Seeing your website go down in front of everyone is a stomach-dropping moment. But a public mistake doesn’t have to be a career-ender—it can be a chance to show professionalism and resilience.

Read more

Spring Boot Configuration Management — Profiles, @ConfigurationProperties, and Secrets

Spring Boot's externalized configuration is powerful and easy to misuse. Getting the property precedence wrong means production uses development values. Embedding secrets in properties files is a security incident waiting to happen. Here is the complete model and the configuration structure that holds up in production.

Read more

What Actually Happens When You Put a Load Balancer in Front of Your App

Load balancers are simple in concept and full of operational surprises in practice. Understanding what they actually do — and what they assume about your application — prevents a category of production incidents that look mysterious until they aren't.

Read more