Strategy Pattern : remplacer if-else par une interface

1. Aperçu

Les longues chaînes if-else ou switch pour choisir un comportement selon le type sont le signe d’un code sur le point de devenir difficile à maintenir. Chaque fois que vous ajoutez un nouveau type, vous devez modifier la même fonction, violant le Principle Ouvert-Fermé. Cet article vous montre comment utiliser le Strategy Pattern pour séparer chaque comportement dans une classe distincte, implémenter une interface commune, puis laisser Spring choisir automatiquement la stratégie appropriée au lieu d’écrire if-else. Après la lecture, vous pourrez réorganiser par vous-même un service de calcul de frais/réductions qui est actuellement confus.

2. Le problème : les chaînes if-else qui s’allongent avec le temps

Supposez que vous ayez PaymentService qui calcule les frais de transaction selon la méthode de paiement :

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

Les problèmes :

  • Ajouter une nouvelle méthode de paiement (par exemple CRYPTO) oblige à modifier directement cette méthode.
  • La logique de calcul des frais de chaque type se mélange dans une fonction, ce qui la rend difficile à tester isolément.
  • Utiliser String pour la comparaison peut causer des erreurs de typage, et il n’y a pas de vérification à la compilation.

3. La solution : définir une interface Strategy

L’idée centrale : extraire le « comportement qui varie selon le type » dans une interface, chaque type étant une implémentation distincte.

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

Chaque stratégie est maintenant une classe indépendante, facile à lire et à tester :

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 n’a désormais besoin que de recevoir une FeeStrategy et de l’appeler, sans connaître la logique interne :

public class PaymentService {

    private final FeeStrategy feeStrategy;

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

    public double calculateFee(double amount) {
        return feeStrategy.calculateFee(amount);
    }
}
  • feeStrategy est injectée via le constructeur, PaymentService n’a pas besoin de savoir si c’est CreditCardFeeStrategy ou un autre type.
  • Ajouter une nouvelle méthode de paiement = ajouter une nouvelle classe implémentant FeeStrategy, sans modifier PaymentService.

4. Choisir la bonne stratégie : éviter les if-else ailleurs

Problème courant : vous supprimez les if-else de PaymentService mais vous les reconstruisez au point d’appel du service. Pour l’éviter, utilisez un Map<String, FeeStrategy> comme registre, enregistrant les stratégies par clé au moment de l’initialisation (factory), sans éparpiller les if-else partout dans le 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 est une map immuable, construite une seule fois lors de l’initialisation de la factory.
  • resolve() est le seul endroit où subsiste une « branche conditionnelle » — mais ce n’est qu’une recherche dans la map, sans logique métier. La logique réelle se trouve dans chaque stratégie.

Essayez :

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

Remarque : si la liste des stratégies est fixe et peu susceptible de changer (2–3 types), un simple if-else reste acceptable. Le Strategy Pattern montre sa valeur lorsque le nombre de types augmente progressivement ou que la logique de chaque type devient complexe et nécessite un test isolé.

5. Exploiter Spring : laisser le conteneur injecter automatiquement la liste des stratégies

Dans Spring Boot, vous n’avez pas besoin d’écrire une Map à la main. Marquez chaque stratégie comme un @Component, et Spring collectera automatiquement tous les beans de la même interface dans une List.

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

Ajoutez getPaymentType() pour que chaque stratégie déclare elle-même le type de paiement qu’elle gère :

@Component
public class CreditCardFeeStrategy implements FeeStrategy {

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

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

La factory reçoit maintenant une List<FeeStrategy> injectée par Spring, et construit la map via le constructeur :

@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 scanne tous les beans implémentant FeeStrategy et les place dans strategyList dans l’ordre d’initialisation.
  • Le constructeur construit la map une seule fois lors de la création de FeeStrategyFactory — aucun if-else n’existe dans tout le processus de sélection de stratégie.
  • Ajouter une nouvelle CryptoFeeStrategy : il suffit de créer la classe, d’ajouter @Component, et Spring la reconnaît automatiquement. FeeStrategyFactory n’a pas besoin de rebuild.

PaymentService utilise cette 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. Écrire des tests pour chaque stratégie et la factory

Puisque chaque stratégie est une classe indépendante, vous testez chacune en isolation sans avoir besoin de 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);
    }
}

Testez la factory pour vous assurer que la « sélection de stratégie » fonctionne correctement, sans se tromper dans les 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 vérifie que la factory retourne le bon type de stratégie selon la clé.
  • shouldThrowWhenTypeNotSupported garantit que le cas où le type de stratégie n’est pas trouvé lève une exception claire, comme l’ancien else — mais maintenant c’est centralisé en un seul endroit.

7. Utiliser une Enum plutôt que des clés String (plus sûr, optionnel)

Si vous craignez les erreurs de typage avec les clés String, vous pouvez utiliser une enum comme clé pour la map, ce qui permet au compilateur de détecter les erreurs plus tôt :

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

Cette approche préserve toute l’architecture ci-dessus, en changeant simplement le type des clés en Map<PaymentType, FeeStrategy>. L’IDE vous suggérera les valeurs enum complètes, évitant les erreurs de saisie.

8. Quand NE PAS utiliser le Strategy Pattern

  • Vous n’avez que 2 branches logiques, et le potentiel d’extension est pratiquement nul — un simple if-else sera plus facile à lire que de créer 2–3 classes supplémentaires.
  • La logique des branches est identique à 90 %, ne différant que par quelques paramètres — utiliser le pattern créerait plusieurs classes presque dupliquées. Dans ce cas, une enum avec des champs de configuration (par exemple le taux de frais) est souvent plus concise.
  • Le comportement n’est pas choisi selon un « type » fixe mais dépend de nombreuses conditions d’exécution complexes (plusieurs critères combinés) — le Strategy Pattern simple ne suffira pas, et vous devrez envisager le Chain of Responsibility ou un moteur de règles.

Remarque : n’appliquez pas le pattern juste parce qu’il « paraît plus professionnel ». Utilisez-le uniquement si vous voyez clairement que le nombre de branches augmentera au fil du temps ou que la logique de chaque branche est suffisamment complexe pour nécessiter un test isolé.

9. Conclusion

Le Strategy Pattern résout exactement le problème des longues chaînes if-else : séparer le comportement qui varie selon le type dans des classes distinctes, tous implémentant une interface commune. Dans Spring Boot, vous tirez parti du mécanisme d’injection de List<Bean> pour collecter automatiquement les stratégies, sans gérer manuellement le registre. Le résultat : ajouter un nouveau type sans toucher au code existant, tester chaque stratégie indépendamment sans effort, et le point « choisir la stratégie » se réduit à une seule location — une simple recherche dans une map, sans éparpiller les if-else partout dans la codebase.

design-patternjavaspring-bootclean-code