Strategy Pattern: Interchangeable Algorithms

Every codebase eventually faces the same problem: a method that started with a simple `if-else` grows into a monster. You need to calculate shipping costs, but the calculation differs by carrier. You...

Key Insights

  • The Strategy pattern eliminates sprawling conditional logic by encapsulating algorithms behind a common interface, allowing runtime selection without modifying client code
  • Unlike inheritance-based approaches, Strategy uses composition to swap behaviors dynamically, making it ideal for scenarios where algorithms change based on user input, configuration, or business rules
  • The pattern shines when you have multiple ways to accomplish the same task, but becomes over-engineering when you only have two or three static options that rarely change

The Problem of Algorithm Sprawl

Every codebase eventually faces the same problem: a method that started with a simple if-else grows into a monster. You need to calculate shipping costs, but the calculation differs by carrier. You need to process payments, but each provider has different requirements. You need to export data, but clients want CSV, JSON, XML, and Excel.

The naive approach looks like this:

public double calculateShipping(Order order, String carrier) {
    if (carrier.equals("FEDEX")) {
        // 50 lines of FedEx-specific logic
    } else if (carrier.equals("UPS")) {
        // 50 lines of UPS-specific logic
    } else if (carrier.equals("USPS")) {
        // 50 lines of USPS-specific logic
    } else if (carrier.equals("DHL")) {
        // Added 6 months later, touching this fragile method again
    }
    // This method is now 200+ lines and growing
}

This code violates the Open/Closed Principle. Every new carrier requires modifying existing code. Testing is painful because you can’t isolate individual algorithms. The method becomes a dumping ground for unrelated logic.

The Strategy pattern solves this by extracting each algorithm into its own class, unified behind a common interface. The client code doesn’t know or care which algorithm it’s using—it just calls the interface method.

Core Concepts and Structure

The Strategy pattern has three participants:

Strategy Interface: Declares the contract that all algorithms must implement. This is typically a single method, though it can include multiple related operations.

Concrete Strategies: Individual classes that implement the Strategy interface, each encapsulating a specific algorithm.

Context: The class that uses a Strategy. It maintains a reference to a Strategy object and delegates algorithm execution to it.

The relationships are straightforward: Context holds a Strategy reference (composition), and all Concrete Strategies implement the Strategy interface. The Context doesn’t know which concrete implementation it’s using—it only knows the interface.

Here’s a basic example with sorting algorithms:

public interface SortStrategy<T extends Comparable<T>> {
    void sort(List<T> items);
}

public class QuickSortStrategy<T extends Comparable<T>> implements SortStrategy<T> {
    @Override
    public void sort(List<T> items) {
        quickSort(items, 0, items.size() - 1);
    }
    
    private void quickSort(List<T> items, int low, int high) {
        if (low < high) {
            int pivotIndex = partition(items, low, high);
            quickSort(items, low, pivotIndex - 1);
            quickSort(items, pivotIndex + 1, high);
        }
    }
    
    private int partition(List<T> items, int low, int high) {
        T pivot = items.get(high);
        int i = low - 1;
        for (int j = low; j < high; j++) {
            if (items.get(j).compareTo(pivot) <= 0) {
                i++;
                Collections.swap(items, i, j);
            }
        }
        Collections.swap(items, i + 1, high);
        return i + 1;
    }
}

public class MergeSortStrategy<T extends Comparable<T>> implements SortStrategy<T> {
    @Override
    public void sort(List<T> items) {
        if (items.size() <= 1) return;
        mergeSort(items, 0, items.size() - 1);
    }
    
    private void mergeSort(List<T> items, int left, int right) {
        if (left < right) {
            int mid = (left + right) / 2;
            mergeSort(items, left, mid);
            mergeSort(items, mid + 1, right);
            merge(items, left, mid, right);
        }
    }
    // merge implementation omitted for brevity
}

public class Sorter<T extends Comparable<T>> {
    private SortStrategy<T> strategy;
    
    public Sorter(SortStrategy<T> strategy) {
        this.strategy = strategy;
    }
    
    public void setStrategy(SortStrategy<T> strategy) {
        this.strategy = strategy;
    }
    
    public void performSort(List<T> items) {
        strategy.sort(items);
    }
}

Implementation Walkthrough

Let’s build a practical payment processing system. This is a classic Strategy use case because payment methods have wildly different implementations but the same goal: charge the customer.

public interface PaymentStrategy {
    PaymentResult process(Money amount, PaymentDetails details);
    boolean supports(String paymentMethod);
    void validate(PaymentDetails details) throws ValidationException;
}

public record PaymentDetails(
    String paymentMethod,
    Map<String, String> attributes
) {}

public record PaymentResult(
    boolean success,
    String transactionId,
    String message
) {}

public class CreditCardStrategy implements PaymentStrategy {
    private final PaymentGateway gateway;
    
    public CreditCardStrategy(PaymentGateway gateway) {
        this.gateway = gateway;
    }
    
    @Override
    public PaymentResult process(Money amount, PaymentDetails details) {
        String cardNumber = details.attributes().get("cardNumber");
        String expiry = details.attributes().get("expiry");
        String cvv = details.attributes().get("cvv");
        
        GatewayResponse response = gateway.charge(cardNumber, expiry, cvv, amount);
        
        return new PaymentResult(
            response.isSuccessful(),
            response.getTransactionId(),
            response.getMessage()
        );
    }
    
    @Override
    public boolean supports(String paymentMethod) {
        return "CREDIT_CARD".equals(paymentMethod);
    }
    
    @Override
    public void validate(PaymentDetails details) throws ValidationException {
        requireAttribute(details, "cardNumber");
        requireAttribute(details, "expiry");
        requireAttribute(details, "cvv");
        
        if (!LuhnValidator.isValid(details.attributes().get("cardNumber"))) {
            throw new ValidationException("Invalid card number");
        }
    }
}

public class PayPalStrategy implements PaymentStrategy {
    private final PayPalClient paypalClient;
    
    public PayPalStrategy(PayPalClient paypalClient) {
        this.paypalClient = paypalClient;
    }
    
    @Override
    public PaymentResult process(Money amount, PaymentDetails details) {
        String authToken = details.attributes().get("authToken");
        
        PayPalPayment payment = paypalClient.executePayment(authToken, amount);
        
        return new PaymentResult(
            payment.getState().equals("approved"),
            payment.getId(),
            payment.getState()
        );
    }
    
    @Override
    public boolean supports(String paymentMethod) {
        return "PAYPAL".equals(paymentMethod);
    }
    
    @Override
    public void validate(PaymentDetails details) throws ValidationException {
        requireAttribute(details, "authToken");
    }
}

public class PaymentProcessor {
    private final List<PaymentStrategy> strategies;
    
    public PaymentProcessor(List<PaymentStrategy> strategies) {
        this.strategies = strategies;
    }
    
    public PaymentResult processPayment(Money amount, PaymentDetails details) {
        PaymentStrategy strategy = strategies.stream()
            .filter(s -> s.supports(details.paymentMethod()))
            .findFirst()
            .orElseThrow(() -> new UnsupportedPaymentMethodException(
                details.paymentMethod()
            ));
        
        strategy.validate(details);
        return strategy.process(amount, details);
    }
}

Notice how PaymentProcessor doesn’t contain any payment-specific logic. Adding cryptocurrency support means creating a new CryptoStrategy class and registering it—zero changes to existing code.

Strategy vs. Alternatives

Strategy vs. State Pattern: Both patterns look structurally identical. The difference is intent. State pattern transitions happen internally based on object state. Strategy selection happens externally based on client needs. If your object automatically switches between behaviors based on internal conditions, use State. If the client explicitly chooses the behavior, use Strategy.

Strategy vs. Template Method: Template Method uses inheritance—you override specific steps in a base class algorithm. Strategy uses composition—you inject entire algorithms. Prefer Strategy when algorithms are completely different. Use Template Method when algorithms share significant structure but differ in specific steps.

Strategy vs. Simple Conditionals: Don’t use Strategy for two or three options that never change. A simple switch statement is more readable and maintainable when the logic is stable and limited. Reach for Strategy when you have four or more algorithms, when algorithms change independently, or when you need to add new algorithms without modifying existing code.

Real-World Applications

Compression Algorithms: A file archiver might support ZIP, GZIP, BZIP2, and LZ4. Each has different speed/compression tradeoffs. Users select based on their needs.

Validation Rules: Form validation often requires different rules per field type, locale, or business unit. Strategies let you compose validation pipelines dynamically.

Data Export: Export functionality commonly supports multiple formats. Each format strategy handles its own serialization logic.

Here’s a pricing calculator demonstrating dynamic discount strategies:

public interface DiscountStrategy {
    Money calculateDiscount(Order order);
    String getDescription();
}

public class BulkDiscountStrategy implements DiscountStrategy {
    private final int threshold;
    private final BigDecimal percentage;
    
    public BulkDiscountStrategy(int threshold, BigDecimal percentage) {
        this.threshold = threshold;
        this.percentage = percentage;
    }
    
    @Override
    public Money calculateDiscount(Order order) {
        if (order.getItemCount() >= threshold) {
            return order.getSubtotal().multiply(percentage);
        }
        return Money.ZERO;
    }
    
    @Override
    public String getDescription() {
        return String.format("%s%% off orders of %d+ items", 
            percentage.multiply(BigDecimal.valueOf(100)), threshold);
    }
}

public class SeasonalDiscountStrategy implements DiscountStrategy {
    private final LocalDate startDate;
    private final LocalDate endDate;
    private final BigDecimal percentage;
    
    @Override
    public Money calculateDiscount(Order order) {
        LocalDate today = LocalDate.now();
        if (!today.isBefore(startDate) && !today.isAfter(endDate)) {
            return order.getSubtotal().multiply(percentage);
        }
        return Money.ZERO;
    }
}

public class PricingCalculator {
    private final List<DiscountStrategy> discountStrategies;
    
    public OrderTotal calculate(Order order) {
        Money subtotal = order.getSubtotal();
        
        List<AppliedDiscount> discounts = discountStrategies.stream()
            .map(strategy -> new AppliedDiscount(
                strategy.getDescription(),
                strategy.calculateDiscount(order)
            ))
            .filter(d -> d.amount().isPositive())
            .toList();
        
        Money totalDiscount = discounts.stream()
            .map(AppliedDiscount::amount)
            .reduce(Money.ZERO, Money::add);
        
        return new OrderTotal(subtotal, discounts, subtotal.subtract(totalDiscount));
    }
}

Testing and Dependency Injection

Strategy pattern makes testing trivial. You can test each strategy in isolation and mock strategies when testing the context.

class PaymentProcessorTest {
    
    @Test
    void processesPaymentWithMatchingStrategy() {
        PaymentStrategy mockStrategy = mock(PaymentStrategy.class);
        when(mockStrategy.supports("TEST")).thenReturn(true);
        when(mockStrategy.process(any(), any()))
            .thenReturn(new PaymentResult(true, "txn-123", "Success"));
        
        PaymentProcessor processor = new PaymentProcessor(List.of(mockStrategy));
        PaymentDetails details = new PaymentDetails("TEST", Map.of());
        
        PaymentResult result = processor.processPayment(Money.of(100), details);
        
        assertThat(result.success()).isTrue();
        assertThat(result.transactionId()).isEqualTo("txn-123");
    }
    
    @Test
    void throwsForUnsupportedPaymentMethod() {
        PaymentProcessor processor = new PaymentProcessor(List.of());
        PaymentDetails details = new PaymentDetails("UNKNOWN", Map.of());
        
        assertThrows(UnsupportedPaymentMethodException.class, 
            () -> processor.processPayment(Money.of(100), details));
    }
}

Spring configuration is straightforward:

@Configuration
public class PaymentConfig {
    
    @Bean
    public PaymentStrategy creditCardStrategy(PaymentGateway gateway) {
        return new CreditCardStrategy(gateway);
    }
    
    @Bean
    public PaymentStrategy paypalStrategy(PayPalClient client) {
        return new PayPalStrategy(client);
    }
    
    @Bean
    public PaymentProcessor paymentProcessor(List<PaymentStrategy> strategies) {
        return new PaymentProcessor(strategies);
    }
}

Spring automatically collects all PaymentStrategy beans into the list.

Pitfalls and Best Practices

Over-engineering: Don’t create a Strategy interface for two implementations that will never grow. YAGNI applies here. Start with conditionals; refactor to Strategy when the third or fourth algorithm appears.

Strategy Explosion: If you have dozens of strategies, you might need a different approach. Consider combining Strategy with Factory or using a rules engine for complex decision logic.

Leaky Abstractions: Strategies should be truly interchangeable. If your context needs to know which concrete strategy it’s using, your abstraction is broken. All strategies must honor the same contract.

Stateful Strategies: Prefer stateless strategies when possible. Stateful strategies complicate threading and reuse. If state is necessary, make it explicit and document thread-safety guarantees.

Apply the Strategy pattern when you have multiple algorithms for the same task, when algorithms need to be selected at runtime, when you want to isolate algorithm code for testing, or when you anticipate adding new algorithms. Skip it for simple, stable conditional logic that doesn’t warrant the abstraction overhead.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.