Design Pattern : La flexibilité du code grâce au Pattern Strategy

Comment rendre votre code plus modulaire et maintenable en sortant de l'enfer des conditions multiples.

Partager
Design Pattern : La flexibilité du code grâce au Pattern Strategy

Auteur : Luigi MULÈ


Dans le développement logiciel, comme dans l'écriture, il y a mille façons de raconter la même histoire. Mettons-nous dans la peau d'un écrivain et imaginons que l'on dispose d'une série de faits bruts sur la vie d'un personnage historique, disons Molière : ses dates, ses pièces, ses scandales. Notre mission est la suivante : écrire et publier son histoire. Pour commencer, on nous demande d'écrire un Roman épique, donc on écrit notre classe StoryTeller et sa méthode tellStory qui va raconter l'histoire dans ce genre. Puis, après un énorme succès, on nous demande de l'adapter en Bande Dessinée pour les plus jeunes, puis en une Biographie austère pour les universitaires, etc. Si nous n'y prenons pas garde, notre classe StoryTeller va rapidement devenir une accumulation massive de if (genre == ROMAN) ... else if (genre == BD). Chaque nouveau format, chaque modification risque de casser la logique des autres. Le texte devient un torchon, difficile à relire et impossible à corriger.

C'est exactement là qu'intervient le Pattern Strategy.

Le problème : L'écrivain couteau suisse

Regardons à quoi ressemble une implémentation basique d'un écrivain qui essaie de maîtriser tous les genres à la fois :

public class StoryTeller {
    // Les faits bruts sont passés en paramètres (événements, dialogues, images...)
    public String tellStory(String genre, List<String> facts, List<String> dialogs) {
        if (genre.equals("NOVEL")) {
            return "Il était une fois... " + facts.get(0) + ". " + dialogs.get(0);
        } else if (genre.equals("COMIC_BOOK")) {
            return "PANEL 1: [Image] " + facts.get(0) + "\nBULLE: " + dialogs.get(0);
        } else if (genre.equals("BIOGRAPHY")) {
            return "Date : 1622. Fait : " + facts.get(0) + ". Source : Archives.";
        }
        return "";
        // Et si on demande un Haïku demain ?
        // Et si les conventions de la BD changent ?
    }
}

Ce code viole un principe fondamental des principes SOLID : Le Principe Ouvert/Fermé (Open/Closed Principle). La classe StoryTeller devrait être ouverte à l'extension (on peut ajouter une infinité de genres littéraires), mais fermée à la modification (on ne devrait pas réécrire l'écrivain pour ajouter de la Poésie). Allons un peu plus loin dans notre métaphore : si nous voulons ajouter le genre "Théâtre", nous sommes obligés de modifier le cerveau de notre écrivain, en prenant le risque d'introduire des coquilles dans la génération du Roman.

La Solution : Le Style est une Stratégie

L'idée du Pattern Strategy est simple mais puissante : Prenez ce qui varie (le style narratif), et séparez-le de ce qui ne varie pas (l'histoire elle-même). Au lieu de donner à notre StoryTeller la capacité d'écrire dans chaque style existant, nous allons plutôt lui apprendre à déléguer cette tâche à une "Plume" spécialisée : une Stratégie Narrative. L'écrivain devient un simple éditeur. Il dit "Écris ces faits avec ce genre". La Stratégie (Roman, BD) sait comment formater le texte. C'est le principe de l'interchangeabilité. Que vous utilisiez une plume de poète ou un crayon de dessinateur, les faits (la naissance de Molière) sont les mêmes, mais le livre produit est radicalement différent.

Comprendre le Pattern Strategy

Maintenant que nous avons identifié le problème (notre écrivain qui mélange tous les genres), passons à la solution. La définition officielle du Gang of Four (GoF) pour le pattern Strategy tient en une phrase :

"Définir une famille d'algorithmes, encapsuler chacun d'eux, et les rendre interchangeables. Strategy permet de mettre à jour l'algorithme indépendamment des clients qui l'utilisent." — GAMMA, Erich, HELM, Richard, JOHNSON, Ralph, VLISSIDES, John, Design Patterns: Elements of Reusable Object-Oriented Software, Reading, Addison-Wesley, 1994.

Dit de façon un peu plus littéraire : Le fond (l'histoire) reste le même, mais la forme (le style) devient interchangeable.

Mettons en place ce système. Pour ce faire, nous allons avoir besoin de trois acteurs :

1. L'Interface

C'est notre charte éditoriale, un contrat qui définit ce que doivent faire tous nos styles, sans dire comment. Pour notre projet littéraire, nous pourrions l'appeler NarrativeStrategy et elle prendrait le contexte, c'est-à-dire l'histoire brute, en paramètre :

public interface NarrativeStrategy {
    String tell(StoryContext context);
}

Que nous écrivions un Roman ou une BD, l'histoire contenue dans le contexte est la même.

2. Les styles spécialisés

Ce sont les implémentations de notre interface. Chacune interprète les faits à sa manière :

  • NovelStrategy : Utilise beaucoup de descriptions (descriptions), ignore les images, met l'accent sur l'émotion.
  • ComicStrategy : Utilise les images (images) et les dialogues (dialogs), ignore les longues descriptions.
  • BiographyStrategy : Utilise les dates (dates) et les faits bruts, soit le plus factuel possible.

L'important ici, c'est l'isolation. Le Romancier ne se soucie pas des contraintes du Dessinateur de BD.

3. Le Contexte (L'Histoire Brute)

C'est notre manuscrit original, nos notes de recherche. C'est lui qui contient toutes nos données brutes. Pour schématiser, l'Histoire ne sait pas si elle finira en livre de poche ou en album cartonné. Elle fournit juste la matière première.

public class StoryContext {
    private NarrativeStrategy strategy;
    
    // Données brutes (la matière première)
    public List<String> facts = Arrays.asList("Il est né à Paris", "Il écrit Le Malade Imaginaire");
    public List<String> dialogs = Arrays.asList("Couvrez ce sein...", "Je meurs, je meurs !");
    public List<String> images = Arrays.asList("img_moliere_jeune.png", "img_theatre.png");

    public void setStrategy(NarrativeStrategy strategy) {
        this.strategy = strategy;
    }

    public void publish() {
        // L'Histoire se passe elle-même ("this") à la stratégie pour être racontée
        String book = this.strategy.tell(this);
        System.out.println(book);
    }
}

La méthode publish est propre. Elle fournit les informations brutes à la stratégie et dit simplement : "Voici mes données, raconte-moi selon tes règles".

La Magie de l'Interchangeabilité

La beauté de ce pattern est que vous pouvez rééditer la même histoire sous un nouveau format instantanément. Imagez votre logiciel d'édition :

  1. L'utilisateur a saisi toutes les données sur la vie de Molière (Context).
  2. Il clique sur "Aperçu Roman" : Le système injecte NovelStrategy. Le texte est fluide, littéraire.
  3. Il clique sur "Aperçu BD" : Le système injecte ComicStrategy. Le texte devient un script visuel (Case 1, Bulle 1...). Les données n'ont pas varié, mais le prisme à travers lequel on les regarde, lui, a muté.

Attention à ne pas confondre

Deux patterns sont souvent confondus avec la Strategy : la Factory et le State. Même si leur structure ressemble à celle du pattern Strategy, il existe une différence majeure : la Strategy est un comportement, tandis que le State est un état, et la Factory une création.

PatternQui décide du changement ?Analogie LittéraireObjectif technique
FactoryL'Éditeur (Client)Vous commandez à l'imprimeur de fabriquer un "Livre" ou un "E-book".Création : Fabriquer l'objet approprié.
StrategyL'Auteur (Client)Vous décidez d'écrire le récit à la 1ère personne ou à la 3ème.Configuration : Choisir un mode d'action interchangeable.
StateLe Récit (L'objet)Le personnage devient "Héroïque" parce qu'il a réussi sa quête.Évolution : Changer de comportement selon des règles internes.

Dans notre analogie :

  1. La Factory est le DRH qui recrute la bonne plume (elle fait le new NovelStrategy()).
  2. La Strategy est la plume qui rédige le livre (elle fait le tell()).
  3. La State est le livre lui-même qui évolue d'un chapitre à l'autre.

Une Factory est souvent utilisée pour choisir quelle Stratégie donner au Contexte initial.

Implémentation : La Maison d'Édition

Assez de métaphores, passons à l'atelier d'écriture. Voyons comment coder ce moteur de génération de livres flexible. Pour rappel, nous voulons éviter le code monolithique où tous les genres sont définis dans des if/else. Commençons par définir ce que signifie "raconter une histoire".

public interface NarrativeStrategy {
    // La stratégie reçoit le contexte (l'histoire brute) pour y piocher ce qu'elle veut
    String tell(StoryContext context);
}

C'est notre contrat. Son rôle est de produire du texte à partir des faits. Créons maintenant nos plumes spécialisées. Chacune va respecter notre contrat et utiliser une partie différente des données de l'histoire.

Le Romancier (Utilise les faits pour faire des phrases longues) :

public class NovelStrategy implements NarrativeStrategy {
    @Override
    public String tell(StoryContext ctx) {
        System.out.println("✒️  ÉCRITURE DU ROMAN...");
        StringBuilder sb = new StringBuilder();
        sb.append("CHAPITRE 1\n");
        // Le romancier brode autour des faits
        sb.append("C'était une époque sombre. ").append(ctx.getFact(0)).append(".\n");
        sb.append("Il murmura doucement : \"").append(ctx.getDialog(0)).append("\".");
        return sb.toString();
    }
}

Le Scénariste BD (Utilise les Images et les Dialogues) :

public class ComicStrategy implements NarrativeStrategy {
    @Override
    public String tell(StoryContext ctx) {
        System.out.println("🎨  CREATION DE LA PLANCHE BD...");
        StringBuilder sb = new StringBuilder();
        sb.append("CASE 1 : [Dessin: ").append(ctx.getImage(0)).append("]\n");
        sb.append("BULLE : ").append(ctx.getDialog(0)).append("\n");
        sb.append("RÉCITATIF : ").append(ctx.getFact(0));
        return sb.toString();
    }
}

Le Biographe (Utilise les Dates et les Faits, style sec) :

public class BiographyStrategy implements NarrativeStrategy {
    @Override
    public String tell(StoryContext ctx) {
        System.out.println("📚  COMPILATION BIOGRAPHIQUE...");
        return "DATE: " + ctx.getDate(0) + " | FAIT HISTORIQUE: " + ctx.getFact(0);
    }
}

Maintenant que nos différentes implémentations ont été codées, nous pouvons passer à la dernière étape. Créer notre StoryContext, qui contient les données brutes de notre histoire.

public class StoryContext {
    private NarrativeStrategy narrativeStyle;
    
    // La base de données de notre histoire
    private List<String> facts = new ArrayList<>();
    private List<String> dialogs = new ArrayList<>();
    private List<String> images = new ArrayList<>();
    private List<String> dates = new ArrayList<>();

    public StoryContext() {
        // Remplissons l'histoire avec des données brutes
        facts.add("Jean-Baptiste Poquelin est né à Paris");
        dates.add("15 janvier 1622");
        dialogs.add("Que diable allait-il faire dans cette galère ?");
        images.add("moliere_portrait.jpg");
        
        // Style par défaut
        this.narrativeStyle = new NovelStrategy();
    }

    // Getters pour que les stratégies accèdent aux données
    public String getFact(int i) { return facts.get(i); }
    public String getDialog(int i) { return dialogs.get(i); }
    public String getImage(int i) { return images.get(i); }
    public String getDate(int i) { return dates.get(i); }

    public void setStrategy(NarrativeStrategy strategy) {
        this.narrativeStyle = strategy;
    }

    public void publish() {
        // Délégation : L'histoire demande à la stratégie de la raconter
        String result = this.narrativeStyle.tell(this);
        System.out.println(result);
    }
}

Très bien, mais comment mettre tout cela en application ? C'est très simple. Nous allons instancier notre StoryContext et lui dire quelle histoire raconter.

StoryContext lifeOfMoliere = new StoryContext();

System.out.println("--- Édition Littéraire ---");
lifeOfMoliere.publish(); 

System.out.println("\n--- Édition Jeunesse (BD) ---");
lifeOfMoliere.setStrategy(new ComicStrategy()); // On change de média !
lifeOfMoliere.publish();

System.out.println("\n--- Édition Universitaire ---");
lifeOfMoliere.setStrategy(new BiographyStrategy());
lifeOfMoliere.publish();
--- Édition Littéraire ---
✒️ ÉCRITURE DU ROMAN...
CHAPITRE 1
C'était une époque sombre. Jean-Baptiste Poquelin est né à Paris.
Il murmura doucement : "Que diable allait-il faire dans cette galère ?".

--- Édition Jeunesse (BD) ---
🎨 CREATION DE LA PLANCHE BD...
CASE 1 : [Dessin: moliere_portrait.jpg]
BULLE : Que diable allait-il faire dans cette galère ?
RÉCITATIF : Jean-Baptiste Poquelin est né à Paris

--- Édition Universitaire ---
📚 COMPILATION BIOGRAPHIQUE...
DATE: 15 janvier 1622 | FAIT HISTORIQUE: Jean-Baptiste Poquelin est né à Paris

C'est ici que l'architecture brille : Les mêmes données brutes ont produit trois œuvres radicalement différentes.

Nous avons franchi une étape importante, mais nous nous confrontons à un problème : comment choisir la bonne stratégie ? Nous avons vu que l'objectif est de s'amputer des if/else et des instanciations manuelles. Nous allons donc tenter de trouver une solution. Pour cela, nos stratégies doivent être capables de dire "Est-ce que je sais gérer ce genre ?". De ce fait, ajoutons une nouvelle méthode à notre interface :

public interface NarrativeStrategy {
    String tell(StoryContext context);
    
    // La méthode clé : "Est-ce que je sais gérer ce genre ?"
    boolean supports(String genre);
}

Comme nous l'avons ajoutée à l'interface, chaque implémentation devra répondre à cette question :

// Dans NovelStrategy
public boolean supports(String genre) {
    return genre.equals("ROMAN");
}
// Dans ComicStrategy
public boolean supports(String genre) {
    return genre.equals("BD");
}
// Dans BiographyStrategy
public boolean supports(String genre) {
    return genre.equals("BIOGRAPHIE");
}

Modifions maintenant notre classe StoryPublisher pour qu'elle puisse choisir la bonne stratégie. Passons une liste de stratégies en paramètre et, dans le constructeur, ajoutons toutes nos implémentations disponibles. Enfin, dans la méthode publish, parcourons la liste pour trouver la stratégie qui supporte le genre demandé.

public class StoryPublisher {
    private List<NarrativeStrategy> strategies;

    public StoryPublisher() {
        // On remplit notre registre manuellement (c'est fastidieux mais ça marche)
        this.strategies = new ArrayList<>();
        this.strategies.add(new NovelStrategy());
        this.strategies.add(new ComicStrategy());
        this.strategies.add(new BiographyStrategy());
    }

    public void publish(String genre, StoryContext context) {
        // On parcourt la liste pour trouver CELUI qui supporte le genre
        NarrativeStrategy selectedStrategy = this.strategies.stream()
            .filter(strategy -> strategy.supports(genre))
            .findFirst()
            .orElseThrow(() -> new RuntimeException("Aucune plume trouvée pour le genre : " + genre));

        // On active la stratégie trouvée
        context.setStrategy(selectedStrategy);
        context.publish();
    }
}

Tous les if/else ont été remplacés par du polymorphisme. C'est mieux, mais il nous reste un dernier souci à régler : nos instanciations manuelles. Tous les new sont faits à la main dans le constructeur. Ce qui peut très vite devenir un problème puisqu'il peut est très facile d'omettre une stratégie dans le constructeur et donc de créer des anomalies.

C'est là que Spring Boot devient magique. Il va se charger d'injecter lui-même toutes les implémentations disponibles de notre stratégie dans notre liste. Ajoutons donc l'annotation @Component à toutes nos implémentations, sans oublier de mettre l'annotation @Service à notre StoryPublisher.

@Service
public class StoryPublisher {

    // Magie de Spring : injecte NovelStrategy, ComicStrategy, BiographyStrategy, etc.
    private final List<NarrativeStrategy> strategies;
    
    public StoryPublisher(List<NarrativeStrategy> strategies) {
        this.strategies = strategies;
    }
    public void publish(String genre, StoryContext context) {
        // On cherche la PREMIÈRE stratégie qui lève la main
        NarrativeStrategy strategy = strategies.stream()
            .filter(s -> s.supports(genre))
            .findFirst()
            .orElseThrow(() -> new RuntimeException("Aucune plume trouvée pour ce genre !"));

        // On l'exécute
        context.setStrategy(strategy);
        context.publish();
    }
}

Pourquoi c'est génial ?

Pour ajouter un nouveau genre (ex: PoetryStrategy), il suffit de créer la classe et de mettre l'annotation @Component. Spring la détecte, l'ajoute à la liste, et notre code de StoryPublisher n'a plus besoin d'être modifié. C'est l'essence même du principe Open/Closed. Mais ce n'est pas fini car on peut aller encore plus loin.

Plutôt que de modifier le StoryContext en settant la stratégie, il est possible de passer le contexte à notre methode tell pour que ce soit la stratégie qui agisse d'elle-même.

@Service
public class StoryPublisherService {

    private Set<NarrativeStrategy> strategies;

    // Spring injecte toutes les stratégies disponibles
    public StoryPublisherService(Set<NarrativeStrategy> strategies) {
        this.strategies = strategies;
    }

    public void publish(String genre, StoryContext context) {
        System.out.println("🔍 Service Spring : recherche d'une plume pour " + genre + "...");

        NarrativeStrategy selectedStrategy = strategies.stream()
                .filter(s -> s.supports(genre))
                .findFirst()
                .orElse(new NovelStrategy()); // Fallback par défaut

        // Polymorphisme : on appelle tell() directement
        // Le context reste passif (DTO), c'est le service qui agit.
        String result = selectedStrategy.tell(context);
        System.out.println(result);
    }
}

Dans cette version, StoryContext ne change jamais. On ne fait pas de setStrategy. Notre service agit comme un intermédiaire entre le contexte et la stratégie. Il sélectionne la bonne plume, et elle écrit sur le bon support et en suivant les bonnes règles. C'est plus simple, plus robuste ("Stateless"), et parfait pour une application web.

Études de Cas : Le Pattern Strategy dans la Nature

L'analogie littéraire de Molière est amusante, mais qu'en est-il du monde réel ? Voici trois scénarios concrets où le pattern Strategy sauve des vies (ou au moins des projets).

1. Le Paiement E-commerce (Approche Classique)

C'est l'exemple d'école, qui correspond parfaitement à l'approche Stateful (le contexte possède sa stratégie).

  • Le Contexte : Panier (contient les articles et le montant : 100€).
  • Le Problème : Payer par Carte demande 16 chiffres. PayPal demande une redirection. Bitcoin demande un hash.
  • La Stratégie : PaymentStrategy.
public class ShoppingCart {
    private PaymentStrategy paymentMethod; // L'état du panier

    public void pay() {
        // Le panier délègue le paiement à sa stratégie courante
        this.paymentMethod.processPayment(this.totalAmount);
    }
}
  • L'application : Au moment du "Checkout", l'utilisateur choisit sa méthode. On fait un cart.setPaymentStrategy(new PayPalStrategy()) et le tour est joué.

2. La Validation de Formulaire (Approche Stateless/Service)

C'est l'exemple parfait pour la variation Stateless (Spring/Service) que nous avons vue.

  • Le Contexte : UserData (Input utilisateur : email, password...). C'est un simple DTO.
  • Le Problème : Les règles de validation sont complexes et réutilisables.
  • La Stratégie : ValidationRule.

On ne modifie pas l'utilisateur. On passe l'utilisateur à une moulinette de règles.

@Service
public class UserValidator {
    private final List<ValidationRule> rules; // Injecté par Spring via le constructeur

    public UserValidator(List<ValidationRule> rules) {
        this.rules = rules;
    }

    public void validate(UserData user) {
        for (ValidationRule rule : rules) {
            // Approche fonctionnelle : Entrée (User) -> Sortie (Exception si invalide)
            rule.check(user); 
        }
    }
}

C'est extrêmement puissant : pour "renforcer la sécurité", il suffit d'ajouter une classe ComplexPasswordRule annotée @Component. Le service Spring la récupère automatiquement et l'applique à tous les futurs utilisateurs, sans toucher au code existant.

3. La Compression de Fichiers (Adaptation Dynamique)

Retour à l'approche Stateful, mais avec un petit twist : le Contexte peut être "intelligent" et changer sa propre stratégie.

  • Le Contexte : CompressionContext (Données brutes).
  • Les Stratégies : ZipStrategyNoCompressionStrategy.
public void compress(File file) {
    // Le contexte demande : "Qui veut s'occuper de ce fichier ?"
    CompressionStrategy strategy = strategies.stream()
        .filter(s -> s.supports(file)) // Chaque stratégie vérifie si elle est apte
        .findFirst()
        .orElse(new ZipStrategy()); // Fallback
    
    strategy.compress(file);
}

Dans NoCompressionStrategy, la logique est déportée : public boolean supports(File f) { return f.getSize() < 100; }

Et dans ZipStrategy : public boolean supports(File f) { return f.getSize() >= 100; }

La machine s'adapte en temps réel aux données qu'elle reçoit, sans un seul if dans votre code de pilotage. C'est le niveau ultime de flexibilité et de propreté.

Conclusion : La liberté de changer

Nous avons voyagé de la cour de Louis XIV avec Molière jusqu'aux entrailles d'un service Spring moderne. Si toutefois, nous ne devions retenir qu'une chose du pattern Strategy, c'est ceci :

La Composition l'emporte sur l'Héritage.

Plutôt que d'enfermer votre code dans des hiérarchies rigides (RomanHistorique qui hérite de Livre), vous créez des Plumes spécialisées qui s'assemblent comme des LEGO autour de votre Écrivain.

La Checklist : Quand l'utiliser ?

Posez-vous ces quatre questions devant votre code :

  1. 🔴 Avez-vous une classe "Monstre" remplie de if (genre == A) ... else ?
  2. 🔴 Avez-vous besoin de changer de comportement en plein vol (runtime) ?
  3. 🔴 Voulez-vous cacher la complexité d'un algorithme au reste de l'application ?
  4. 🔥 Voulez-vous pouvoir ajouter des fonctionnalités (un nouveau genre) sans jamais modifier votre code existant ? (Principe Open/Closed).

Si vous répondez OUI à une de ces questions, il est temps de sortir votre interface Strategy.

Le Mot de la Fin

Le pattern Strategy n'est pas qu'une astuce technique. C'est une philosophie de design qui prône l'ouverture et la flexibilité.

En couplant ce pattern avec un registre (comme celui de Spring), votre application devient modulaire. Elle n'est plus un bloc de béton figé, mais un système évolutif : ajouter un nouveau mode de paiement ou un genre littéraire revient simplement à "brancher" une nouvelle plume, sans jamais avoir à modifier le code qui les pilote.


Codez bien, codez souple.