Strategy Pattern: Replace if-else with an Interface

1. Overview

Long chains of if-else or switch statements to choose behavior based on type are a sign that your code will soon become difficult to maintain. Every time you add a new type, you have to modify the same function, violating the Open/Closed Principle. This guide shows you how to use the Strategy Pattern to separate each behavior into its own class, implement the same interface, and let Spring automatically select the appropriate strategy instead of you writing if-else. After reading, you’ll be able to refactor a messy fee or discount calculation service on your own.

2. Problem: if-else chains grow over time

Suppose you have a PaymentService that calculates transaction fees based on payment method:

public class PaymentService {

    public double calculateFee(String paymentType, double amount) {
        if (paymentType.equals("CREDIT_CARD")) {
            return amount * 0.03;
        } else if (paymentType.equals("BANK_TRANSFER")) {
            return amount * 0.01;
        } else if (paymentType.equals("E_WALLET")) {
            return amount * 0.015;
        } else {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
    }
}

The problems:

  • Adding a new payment method (like CRYPTO) requires modifying this method directly.
  • Fee calculation logic for each type is mixed into one function, making it hard to test individually.
  • Using String comparisons is error-prone and offers no compile-time checks.

3. Solution: Define a Strategy interface

The core idea: extract “behavior that varies by type” into an interface, where each type is a separate implementation.

public interface FeeStrategy {
    double calculateFee(double amount);
}

Each strategy is now an independent class, easy to read and test:

public class CreditCardFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}
public class BankTransferFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.01;
    }
}
public class EWalletFeeStrategy implements FeeStrategy {
    @Override
    public double calculateFee(double amount) {
        return amount * 0.015;
    }
}

PaymentService now only needs to receive a FeeStrategy and call it, without knowing anything about the logic inside:

public class PaymentService {

    private final FeeStrategy feeStrategy;

    public PaymentService(FeeStrategy feeStrategy) {
        this.feeStrategy = feeStrategy;
    }

    public double calculateFee(double amount) {
        return feeStrategy.calculateFee(amount);
    }
}
  • feeStrategy is injected through the constructor; PaymentService doesn’t need to know whether it’s CreditCardFeeStrategy or something else.
  • Adding a new payment method = adding a new class that implements FeeStrategy, without modifying PaymentService.

4. Choosing the right strategy: avoid if-else elsewhere

A common mistake: you remove if-else from PaymentService but recreate it in the calling code. To avoid this, use a Map<String, FeeStrategy> as a registry, registering strategies by key at initialization (factory), not scattering if-else throughout your code.

public class FeeStrategyFactory {

    private final Map<String, FeeStrategy> strategies;

    public FeeStrategyFactory() {
        this.strategies = Map.of(
            "CREDIT_CARD", new CreditCardFeeStrategy(),
            "BANK_TRANSFER", new BankTransferFeeStrategy(),
            "E_WALLET", new EWalletFeeStrategy()
        );
    }

    public FeeStrategy resolve(String paymentType) {
        FeeStrategy strategy = strategies.get(paymentType);
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
        return strategy;
    }
}
  • strategies is an immutable map, built once at factory initialization.
  • resolve() is the only place where “branching logic” remains — but it just looks up in the map, containing no business logic. The actual logic lives in each strategy.

Try it out:

FeeStrategyFactory factory = new FeeStrategyFactory();
FeeStrategy strategy = factory.resolve("E_WALLET");
PaymentService service = new PaymentService(strategy);

double fee = service.calculateFee(1_000_000);
System.out.println(fee);
15000.0

Note: if the list of strategies is fixed and rarely changes (2–3 types), a simple if-else is still fine. Strategy Pattern proves its value when the number of types grows over time or the logic for each type is complex and needs independent testing.

5. Leverage Spring: let the container automatically inject the list of strategies

In Spring Boot, you don’t need to write the Map manually. Mark each strategy as a @Component, and Spring will automatically collect all beans implementing the same interface into a List.

public interface FeeStrategy {
    String getPaymentType();
    double calculateFee(double amount);
}

Add getPaymentType() so each strategy declares which type it serves:

@Component
public class CreditCardFeeStrategy implements FeeStrategy {

    @Override
    public String getPaymentType() {
        return "CREDIT_CARD";
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}

The factory now receives List<FeeStrategy> injected by Spring, building the map in the constructor:

@Component
public class FeeStrategyFactory {

    private final Map<String, FeeStrategy> strategies = new HashMap<>();

    public FeeStrategyFactory(List<FeeStrategy> strategyList) {
        for (FeeStrategy strategy : strategyList) {
            strategies.put(strategy.getPaymentType(), strategy);
        }
    }

    public FeeStrategy resolve(String paymentType) {
        FeeStrategy strategy = strategies.get(paymentType);
        if (strategy == null) {
            throw new IllegalArgumentException("Unsupported type: " + paymentType);
        }
        return strategy;
    }
}
  • Spring scans all beans implementing FeeStrategy and injects them into strategyList in initialization order.
  • The constructor builds the map once when FeeStrategyFactory is created — no if-else anywhere in the strategy selection process.
  • Adding a new CryptoFeeStrategy: just create the class, add @Component, and Spring automatically recognizes it. FeeStrategyFactory doesn’t need rebuilding.

PaymentService uses this factory via @Service:

@Service
public class PaymentService {

    private final FeeStrategyFactory feeStrategyFactory;

    public PaymentService(FeeStrategyFactory feeStrategyFactory) {
        this.feeStrategyFactory = feeStrategyFactory;
    }

    public double calculateFee(String paymentType, double amount) {
        FeeStrategy strategy = feeStrategyFactory.resolve(paymentType);
        return strategy.calculateFee(amount);
    }
}

6. Test each strategy and the factory

Because each strategy is an independent class, you can test each one without needing any mocks:

class CreditCardFeeStrategyTest {

    private final CreditCardFeeStrategy strategy = new CreditCardFeeStrategy();

    @Test
    void shouldCalculate3PercentFee() {
        double fee = strategy.calculateFee(1_000_000);

        assertThat(fee).isEqualTo(30_000.0);
    }
}

Tests for the factory ensure that “selecting a strategy” works correctly, without falling into the wrong if-else:

class FeeStrategyFactoryTest {

    private final FeeStrategyFactory factory = new FeeStrategyFactory(
        List.of(new CreditCardFeeStrategy(), new EWalletFeeStrategy())
    );

    @Test
    void shouldResolveCorrectStrategy() {
        FeeStrategy strategy = factory.resolve("E_WALLET");

        assertThat(strategy).isInstanceOf(EWalletFeeStrategy.class);
    }

    @Test
    void shouldThrowWhenTypeNotSupported() {
        assertThatThrownBy(() -> factory.resolve("CRYPTO"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("CRYPTO");
    }
}
  • shouldResolveCorrectStrategy confirms the factory returns the correct strategy type by key.
  • shouldThrowWhenTypeNotSupported ensures that when a strategy isn’t found, it throws a clear error, like the old else behavior did — but now it’s centralized in one place.

7. Use Enum instead of String keys (safer option)

If you’re concerned about typos with String keys, you can use an enum as the map key, allowing the compiler to catch errors early:

public enum PaymentType {
    CREDIT_CARD, BANK_TRANSFER, E_WALLET
}
public interface FeeStrategy {
    PaymentType getPaymentType();
    double calculateFee(double amount);
}

This approach preserves the entire architecture above, only changing the key type in Map<PaymentType, FeeStrategy>. Your IDE will suggest all enum values, preventing typos.

8. When NOT to use Strategy Pattern

  • You only have 2 branches, with almost no room for expansion — a simple if-else is easier to read than creating 2–3 extra classes.
  • The logic of each branch is 90% identical, differing only in a few parameters — using Strategy will create many nearly-duplicate classes. In this case, an enum with configuration fields (like fee percentage) is often more concise.
  • The behavior isn’t selected based on a “fixed type” but depends on complex runtime conditions (multiple combined criteria) — Strategy Pattern alone isn’t sufficient; you should consider Chain of Responsibility or a rule engine.

Note: don’t apply a pattern just because it “looks more professional.” Only use it when you clearly see that the number of branches will grow over time or the logic for each branch is complex enough to warrant independent testing.

9. Conclusion

Strategy Pattern solves the exact problem of long if-else chains: separate behaviors that vary by type into individual classes implementing a common interface. In Spring Boot, you leverage the mechanism of injecting List<Bean> to automatically collect strategies without needing to manage the registry manually. The result: adding a new type doesn’t touch existing code, each strategy is tested independently with ease, and the “strategy selection” happens in only one place — a simple map lookup, no if-else scattered throughout the codebase.

design-patternjavaspring-bootclean-code