Formation SOLID en pratique

O — Ouvert / Fermé : les frais de livraison

Remplacer le switch des transporteurs (Colissimo, Chronopost, retrait magasin) par le pattern Strategy et ajouter le point relais sans toucher au code existant.

Leçon gratuite

Objectif de la leçon

Remplacer un switch sur les modes de livraison par le pattern Strategy, pour ajouter un transporteur sans toucher au code existant.

La règle

Le code doit être ouvert à l’extension et fermé à la modification. On ajoute un comportement en ajoutant une classe, pas en modifiant une classe qui fonctionne déjà en production.

L’analogie : une multiprise. Pour brancher un nouvel appareil, vous ne recâblez pas le mur, vous ajoutez une prise.

Sans pattern : le switch des frais de livraison

public class CalculateurLivraison {

    public double frais(String mode, Panier panier) {
        switch (mode) {
            case "COLISSIMO":
                return panier.totalHT() >= 50 ? 0.0 : 4.90;
            case "CHRONOPOST":
                return 9.90;
            case "RETRAIT_MAGASIN":
                return 0.0;
            default:
                throw new IllegalArgumentException("Mode inconnu : " + mode);
        }
    }
}

Le problème

Octobre : « Pour le Black Friday, on ajoute le point relais à 3,50 € ». Novembre : « la livraison express le jour même ». Janvier : « la livraison internationale ».

  • Chaque nouveau transporteur oblige à rouvrir CalculateurLivraison, testé et en production.
  • Le même switch existe souvent ailleurs : délai de livraison, libellé sur la page panier, choix de l’étiquette. On en oublie un, et c’est l’incident en pleine campagne.
  • Le test unitaire grossit et devient illisible.

Avec pattern : Strategy

Pattern utilisé : Strategy. Chaque mode de livraison devient une classe qui respecte un contrat commun, et regroupe tout ce qui le concerne (frais, délai, libellé).

// Le contrat
public interface ModeLivraison {
    String code();
    String libelle();
    double frais(Panier panier);
    int delaiJours();
}

// Une classe par transporteur
@Component
public class Colissimo implements ModeLivraison {
    private static final double SEUIL_GRATUIT = 50.0;
    public String code() { return "COLISSIMO"; }
    public String libelle() { return "Colissimo à domicile"; }
    public double frais(Panier p) { return p.totalHT() >= SEUIL_GRATUIT ? 0.0 : 4.90; }
    public int delaiJours() { return 3; }
}

@Component
public class Chronopost implements ModeLivraison {
    public String code() { return "CHRONOPOST"; }
    public String libelle() { return "Chronopost express"; }
    public double frais(Panier p) { return 9.90; }
    public int delaiJours() { return 1; }
}

@Component
public class RetraitMagasin implements ModeLivraison {
    public String code() { return "RETRAIT_MAGASIN"; }
    public String libelle() { return "Retrait en magasin"; }
    public double frais(Panier p) { return 0.0; }
    public int delaiJours() { return 2; }
}

// Le calculateur ne changera plus jamais
@Service
public class CalculateurLivraison {
    private final Map<String, ModeLivraison> modes;

    // Spring injecte automatiquement toutes les implémentations
    public CalculateurLivraison(List<ModeLivraison> modes) {
        this.modes = modes.stream()
            .collect(Collectors.toMap(ModeLivraison::code, m -> m));
    }

    public double frais(String code, Panier panier) {
        ModeLivraison mode = modes.get(code);
        if (mode == null) throw new IllegalArgumentException("Mode inconnu : " + code);
        return mode.frais(panier);
    }

    public Collection<ModeLivraison> disponibles() { return modes.values(); }
}

Le point relais arrive ? On ajoute un fichier, et c’est tout. Il apparaît automatiquement dans la page panier :

@Component
public class PointRelais implements ModeLivraison {
    public String code() { return "POINT_RELAIS"; }
    public String libelle() { return "Point relais"; }
    public double frais(Panier p) { return 3.50; }
    public int delaiJours() { return 4; }
}

Le schéma : avant / après

CalculateurLivraisonswitch (mode) {  case COLISSIMO …  case CHRONOPOST …  case RETRAIT_MAGASIN …  case ??? ← à ajouterPoint relaisnouveau besoin→ on rouvre la classeChaque nouveau transporteur = modifier une classe en prod→ risque de régression sur tous les modes existants
Avant Le switch grossit à chaque campagne
CalculateurLivraisonne change plusutilise«interface»ModeLivraisonColissimoexistantChronopostexistantRetraitMagasinexistantPointRelais+ ajoutéAjouter = un nouveau fichier. Rien d’existant n’est modifié.
Après Strategy : une classe par transporteur

Comment lire le schéma

  • Avant : le nouveau besoin (en jaune) oblige à rouvrir la classe rouge et à ajouter une branche au switch.
  • Après : CalculateurLivraison ne connaît que l’interface ModeLivraison. Il ne change plus.
  • Chaque transporteur implémente l’interface. PointRelais (en jaune) est ajouté à côté des autres, sans toucher à l’existant.
  • Légende : flèche pleine = « utilise » · flèche à triangle blanc = « implémente / hérite » · cadre pointillé jaune = interface · vert = conforme · rouge = problème.

Astuce d’expert

Java 21 offre une alternative quand les variantes sont fermées et connues à la compilation : une sealed interface + switch avec pattern matching. Le compilateur signale tout cas oublié (switch exhaustif, sans default). Gardez Strategy + injection Spring quand de nouvelles variantes arrivent souvent (transporteurs, promos) ; préférez le sealed pour un domaine stable (statuts de commande, par exemple).

Ce qu’on gagne

CritèreSans patternAvec pattern
Ajouter un transporteurModifier une classe en productionAjouter une classe
Risque de régressionTous les modes existantsZéro sur l’existant
TestsUn gros test qui grossitUn petit test par transporteur
RéutilisationLe switch recopié (frais, délai, libellé)Un seul objet sert au panier, à la commande et à l’étiquette
Recette avant le Black FridayTout re-testerTester seulement la nouvelle classe

Bonnes pratiques

  • Symptôme : un switch ou une cascade de if sur un type (transporteur, promo, moyen de paiement) qui grossit à chaque campagne.
  • Regroupez dans la stratégie tout ce qui dépend du type : prix, délai, libellé.
  • Avec Spring, injectez une List<Interface> : chaque nouvelle classe @Component est découverte automatiquement.

Erreurs fréquentes

  • Créer une Strategy pour deux cas qui ne bougeront jamais : un if suffit.
  • Laisser un switch dans un autre service « juste pour le libellé » : le principe O est cassé ailleurs.

Lab · 30 min · Les codes promo

Le service suivant gère les codes promo avec un switch :

public double appliquerPromo(String type, double valeur, Panier panier) {
    switch (type) {
        case "POURCENTAGE":  return panier.totalHT() * (1 - valeur / 100);
        case "MONTANT_FIXE": return Math.max(0, panier.totalHT() - valeur);
        default: return panier.totalHT();
    }
}
  1. Créez une interface Promotion et une classe par type.
  2. Le marketing ajoute « 2 achetés, le 3e offert » : implémentez-le sans modifier les classes existantes.
  3. Écrivez un test par promotion.

À retenir

  • Nouveau besoin = nouvelle classe, pas nouvelle branche de switch.
  • Strategy = une interface + une classe par variante.
  • Ne pas l’appliquer quand il n’y a pas de variation réelle.

Quiz rapide

  1. Quel pattern remplace un switch sur le transporteur ? → Strategy.
  2. Combien de fichiers existants modifiez-vous pour ajouter le point relais ? → Zéro.
  3. Comment Spring trouve-t-il les transporteurs ? → En injectant List<ModeLivraison>.

Suivez votre progression. Créez un compte gratuit pour cocher vos leçons et reprendre là où vous en étiez.

Créer un compte gratuit