Strategy Pattern: if-elseをインターフェースで置き換える

1. 概要

タイプに応じた動作を選択するための長いif-elseやswitch文は、コードが保守困難になってきた兆候です。新しいタイプを追加するたびに同じメソッドを修正する必要があり、Open/Closed原則に違反します。この記事では、Strategy Patternを使い、各動作を個別のクラスに分離して同じインターフェースを実装させることで、if-elseを書く代わりにSpringが自動的に適切なstrategyを選択する方法を説明します。読み終わると、複雑な手数料/割引計算サービスを自分でリファクタリングできるようになります。

2. 問題: if-elseが時間とともに肥大化する

支払い方法に応じて手数料を計算するPaymentServiceがあるとしましょう:

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);
        }
    }
}

問題:

  • 新しい支払い方法(例:CRYPTO)を追加するには、このメソッドを直接修正する必要があります。
  • 各タイプの手数料計算ロジックが1つの関数に混在しており、個別にテストするのが困難です。
  • Stringでの比較は誤字を招きやすく、コンパイル時のチェックがありません。

3. 解決策: Strategy インターフェースを定義する

中核となるアイデアは、「タイプに応じて変わる動作」をインターフェースに抽出し、各タイプは個別の実装にすることです。

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

各strategyは独立したクラスになり、読みやすく、テストしやすくなります:

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はFeeStrategyを受け取って呼び出すだけでよく、内部ロジックについて何も知る必要がありません:

public class PaymentService {

    private final FeeStrategy feeStrategy;

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

    public double calculateFee(double amount) {
        return feeStrategy.calculateFee(amount);
    }
}
  • feeStrategyはコンストラクタを通じてinjectされ、PaymentServiceはCreditCardFeeStrategyかそれ以外かを知る必要がありません。
  • 新しい支払い方法を追加する = FeeStrategyを実装する新しいクラスを追加する、PaymentServiceは修正しない。

4. strategyを正しく選択する: 他の場所でif-elseを避ける

よくある問題:PaymentServiceからif-elseを削除しますが、serviceを呼び出す場所で新しくif-elseを書いてしまう。これを避けるには、Map<String, FeeStrategy>をregistryとして使い、初期化時(factory)にキーごとにstrategyを登録して、コード全体にif-elseを散らさないようにする。

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は不変のmapで、factory初期化時に1回だけ構築されます。
  • resolve()は唯一の「条件分岐」が残っている場所ですが、mapのlookupだけで、ビジネスロジックを含みません。本来のロジックは各strategyにあります。

試してみましょう:

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

注意: strategyのリストが固定で、ほとんど変更されない場合(2~3種類)、シンプルなif-elseで十分です。Strategy Patternが価値を発揮するのは、タイプの数が時間とともに増え、または各タイプのロジックが複雑になり、個別のテストが必要な場合です。

5. Springを活用する: containerが自動的にstrategyリストをinjectする

Spring Bootでは、Mapを手動で書く必要はありません。各strategyに@Componentを付けると、Spring が同じインターフェースを実装するすべてのbeanを自動的にListに収集します。

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

各strategyが自分がどのタイプに対応するかを宣言するためにgetPaymentType()を追加します:

@Component
public class CreditCardFeeStrategy implements FeeStrategy {

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

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

Factoryは現在、Spring がinjectしたList<FeeStrategy>を受け取り、コンストラクタで自動的にmapを構築します:

@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 はFeeStrategyを実装するすべてのbeanをスキャンし、初期化順序に従ってstrategyListに渡します。
  • コンストラクタはFeeStrategyFactoryが作成されるときに1回だけmapを構築します — strategy選択のフロー全体にif-elseはありません。
  • 新しいCryptoFeeStrategyを追加:クラスを作成して@Componentを付けるだけで、Spring が自動的に認識します。FeeStrategyFactoryは再構築不要。

PaymentServiceは@Serviceを通じてこのfactoryを使用します:

@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. 各strategyとfactoryのテストを書く

各strategyは独立したクラスなので、mockなしで個別にテストできます:

class CreditCardFeeStrategyTest {

    private final CreditCardFeeStrategy strategy = new CreditCardFeeStrategy();

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

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

factoryのテストは「strategyの選択」が正しく機能し、誤った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は、factoryがキーに応じて正しいタイプのstrategyを返すことを確認します。
  • shouldThrowWhenTypeNotSupportedは、strategyが見つからない場合に明確なエラーが報告されることを確認します。以前のelseの動作と同じですが、今は1か所に集中しています。

7. String キーの代わりにEnumを使用する(より安全な選択肢)

Stringキーの誤字が心配な場合は、enumをmapのキーとして使用し、コンパイラに早期にエラーを捕捉させることができます:

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

この方法は上記のアーキテクチャ全体を保ち、Map<PaymentType, FeeStrategy>のキーの型だけを変更します。IDEは列挙値の完全なサジェストを提供し、文字列の誤字入力を回避できます。

8. Strategy Patternを使用してはいけない場合

  • ロジック分岐が2つだけで、拡張の余地がほぼない場合 — シンプルなif-elseは、2~3個クラスを作成するより読みやすいです。
  • 各分岐のロジックが90%同じで、いくつかのパラメータだけが異なる場合 — strategyを使用すると、ほぼ重複したクラスが多くなります。この場合、設定フィールド(例:手数料率)を持つenumの方がコンパクトです。
  • 選択される動作が固定された「タイプ」ではなく、複雑なランタイム条件に依存する場合(複数の基準の組み合わせ)— Strategy Patternだけでは不十分で、Chain of Responsibilityまたはrule engineを検討する必要があります。

注意: パターンが「より専門的に見える」からという理由で適用しないでください。分岐の数が時間とともに増えるか、各分岐のロジックが個別テストが必要なほど複雑であると明確に判断できる場合にのみ使用してください。

9. 結論

Strategy Patternは長いif-else文の問題を正しく解決します:タイプに応じて変わる動作を個別のクラスに分離し、同じインターフェースを実装させます。Spring Bootでは、List<Bean>のinjectメカニズムを活用してstrategyを自動的に収集でき、registryを手動で管理する必要がありません。結果として、新しいタイプを追加しても既存のコードに触れず、各strategyは独立してテストしやすく、「strategyの選択」は唯一の場所にのみ存在します — mapのlookupで、コードベース全体に散在するif-elseはありません。

design-patternjavaspring-bootclean-code