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.