Aller au contenu principal

Concept #010

Design Patterns

Le Design Pattern Strategy

#0108 min de lecture
  • design-patterns
  • architecture
  • programmation
Strategy : remplacer les if/else par des stratégies interchangeables

Le Design Pattern Strategy en une phrase

Strategy est un patron de conception qui encapsule une famille d'algorithmes, les place dans des classes séparées, et les rend interchangeables. Le code client choisit quelle stratégie utiliser — sans jamais connaître son implémentation interne.

Si vous avez des chaînes de if (type === 'A') ... else if (type === 'B') ... else if (type === 'C') qui grossissent à chaque nouvelle demande, c'est le signe que vous avez besoin de Stratégie.


Le problème : les chaînes de if/else

Imaginez un système de e-commerce qui applique des réductions selon le profil du client. En première version, c'est simple : les membres VIP ont 20% de remise, les autres paient plein tarif.

function calculatePrice(basePrice: number, customerType: string): number {
  if (customerType === 'vip') {
    return basePrice * 0.8; // -20%
  } else {
    return basePrice;
  }
}

Simple. Lisible. Et puis les demandes arrivent.

"On veut un programme de fidélité avec 10% pour les membres standard." "On lance une promo Black Friday, -30% pour tout le monde." "Les étudiants vérifiés ont droit à -15%." "Les grossistes reçoivent une remise dégressive selon le volume."

Quelques semaines plus tard, la fonction ressemble à ça :

function calculatePrice(
  basePrice: number,
  customerType: string,
  quantity: number,
  isBF: boolean
): number {
  if (isBF) {
    return basePrice * 0.7;
  } else if (customerType === 'vip') {
    return basePrice * 0.8;
  } else if (customerType === 'student') {
    return basePrice * 0.85;
  } else if (customerType === 'member') {
    return basePrice * 0.9;
  } else if (customerType === 'wholesaler') {
    if (quantity > 100) {
      return basePrice * 0.75;
    } else if (quantity > 50) {
      return basePrice * 0.82;
    } else {
      return basePrice * 0.88;
    }
  } else {
    return basePrice;
  }
}

Cette fonction a plusieurs raisons de changer : ajouter un nouveau type de client, modifier un taux, corriger un bug dans la logique grossiste. C'est une violation flagrante du principe de responsabilité unique. Et tester chaque cas devient un cauchemar combinatoire.


Le principe Strategy : Context, Interface, Stratégies concrètes

Le pattern Strategy repose sur trois éléments :

1. L'interface Strategy — Le contrat que toutes les stratégies doivent respecter. Elle expose une seule méthode qui fait le calcul (ou l'algorithme).

2. Les ConcreteStrategies — Les implémentations concrètes, une par algorithme. Chaque classe est autonome, focalisée sur un seul cas, et facilement testable.

3. Le Context — La classe qui utilise une stratégie. Elle ne sait pas laquelle elle reçoit, elle se contente de l'appeler. C'est le code client qui choisit quelle stratégie injecter.

Context
  ├── stratégie: DiscountStrategy   ← référence à l'interface
  └── calculatePrice()              ← délègue à la stratégie

DiscountStrategy (interface)
  └── apply(basePrice, quantity): number

ConcreteStrategies
  ├── NoDiscountStrategy
  ├── VipDiscountStrategy
  ├── StudentDiscountStrategy
  ├── MemberDiscountStrategy
  ├── WholesalerDiscountStrategy
  └── BlackFridayDiscountStrategy

Exemple concret : un système de calcul de prix

Voici comment refactorer notre exemple avec le pattern Strategy en TypeScript.

1. L'interface commune

// Le contrat que toutes les stratégies de réduction doivent respecter
interface DiscountStrategy {
  apply(basePrice: number, quantity: number): number;
  readonly label: string;
}

2. Les stratégies concrètes

Chaque algorithme vit dans sa propre classe. Isolé, lisible, testable indépendamment.

class NoDiscountStrategy implements DiscountStrategy {
  readonly label = 'Tarif plein';
 
  apply(basePrice: number): number {
    return basePrice;
  }
}
 
class VipDiscountStrategy implements DiscountStrategy {
  readonly label = 'Remise VIP (-20%)';
 
  apply(basePrice: number): number {
    return basePrice * 0.8;
  }
}
 
class StudentDiscountStrategy implements DiscountStrategy {
  readonly label = 'Remise étudiant (-15%)';
 
  apply(basePrice: number): number {
    return basePrice * 0.85;
  }
}
 
class MemberDiscountStrategy implements DiscountStrategy {
  readonly label = 'Remise membre (-10%)';
 
  apply(basePrice: number): number {
    return basePrice * 0.9;
  }
}
 
class WholesalerDiscountStrategy implements DiscountStrategy {
  readonly label = 'Remise grossiste (dégressive)';
 
  apply(basePrice: number, quantity: number): number {
    if (quantity > 100) return basePrice * 0.75;
    if (quantity > 50)  return basePrice * 0.82;
    return basePrice * 0.88;
  }
}
 
class BlackFridayDiscountStrategy implements DiscountStrategy {
  readonly label = 'Black Friday (-30%)';
 
  apply(basePrice: number): number {
    return basePrice * 0.7;
  }
}

3. Le Context : la classe PriceCalculator

class PriceCalculator {
  private strategy: DiscountStrategy;
 
  constructor(strategy: DiscountStrategy) {
    this.strategy = strategy;
  }
 
  // On peut changer de stratégie à la volée si nécessaire
  setStrategy(strategy: DiscountStrategy): void {
    this.strategy = strategy;
  }
 
  calculate(basePrice: number, quantity: number = 1): {
    finalPrice: number;
    label: string;
    savings: number;
  } {
    const finalPrice = this.strategy.apply(basePrice, quantity);
    return {
      finalPrice,
      label: this.strategy.label,
      savings: basePrice - finalPrice,
    };
  }
}

4. Le code client : choisit la stratégie selon le contexte

function getDiscountStrategy(
  customer: Customer,
  isBlackFriday: boolean
): DiscountStrategy {
  if (isBlackFriday) return new BlackFridayDiscountStrategy();
 
  switch (customer.type) {
    case 'vip':        return new VipDiscountStrategy();
    case 'student':    return new StudentDiscountStrategy();
    case 'member':     return new MemberDiscountStrategy();
    case 'wholesaler': return new WholesalerDiscountStrategy();
    default:           return new NoDiscountStrategy();
  }
}
 
// Utilisation
const customer = { type: 'vip', name: 'Alice' };
const strategy = getDiscountStrategy(customer, false);
const calculator = new PriceCalculator(strategy);
 
const result = calculator.calculate(100, 1);
console.log(`${result.label} : ${result.finalPrice}€ (économie : ${result.savings}€)`);
// → "Remise VIP (-20%) : 80€ (économie : 20€)"

Remarquez que la sélection de stratégie est maintenant le seul endroit qui contient un switch. Et ce switch ne fait qu'une chose : choisir une stratégie. Il ne calcule rien.


Avantages

Principe Open/Closed respecté

Ajouter une nouvelle réduction pour les "abonnés premium" ? Vous créez une PremiumSubscriberDiscountStrategy, vous ajoutez un case 'premium' dans la sélection de stratégie, et c'est tout. Le PriceCalculator n'est pas touché. Les autres stratégies ne sont pas touchées. Le risque de régression est quasi nul.

Testabilité unitaire

Chaque stratégie est une classe autonome avec une seule responsabilité. Tester la logique dégressive du grossiste ne nécessite plus de simuler tous les autres cas :

describe('WholesalerDiscountStrategy', () => {
  const strategy = new WholesalerDiscountStrategy();
 
  it('applique -25% au-delà de 100 unités', () => {
    expect(strategy.apply(100, 101)).toBe(75);
  });
 
  it('applique -18% entre 50 et 100 unités', () => {
    expect(strategy.apply(100, 75)).toBe(82);
  });
 
  it('applique -12% en dessous de 50 unités', () => {
    expect(strategy.apply(100, 20)).toBe(88);
  });
});

Trois tests, trois cas, zéro bruit.

Interchangeabilité à la volée

Le Context peut changer de stratégie en cours d'exécution. Un panier d'achat peut passer automatiquement à la stratégie Black Friday dès que le compte à rebours démarre, sans que l'utilisateur ne recharge sa page.


Quand l'utiliser ?

Le pattern Strategy est pertinent quand vous repérez ces signaux :

  • Une fonction avec de nombreux if/else if qui traitent des variantes d'un même algorithme. C'est le signal le plus fiable.

  • Des algorithmes qui doivent pouvoir être échangés à l'exécution — tri différent selon la taille des données, compression selon le format de fichier, rendu selon la plateforme cible.

  • Des règles métier qui varient et évoluent souvent — tarification, scoring, validation, formatage. Si le Product Owner change les règles tous les mois, isoler chaque règle dans sa propre classe réduit la surface de risque à chaque modification.

  • Des tests complexes à écrire sur une seule fonction qui fait trop de choses. La Strategy vous force à découper, ce qui rend les tests triviaux.


Quand c'est de la sur-ingénierie ?

Comme tout pattern, Strategy a un coût : plus de fichiers, plus de classes, plus d'indirection. Ce surcoût se justifie quand les algorithmes sont nombreux et amenés à évoluer. Il ne se justifie pas quand :

  • Vous n'avez que deux cas, et ils sont stables. Un simple if/else est plus lisible qu'un pattern complet. Ne combattez pas une complexité qui n'existe pas encore.

  • Les algorithmes ne partagent pas de signature commune. Si chaque variante prend des paramètres radicalement différents, forcer une interface commune produit un code artificiel et plus difficile à comprendre.

  • Vous êtes en phase d'exploration. Si les règles métier ne sont pas encore stabilisées, attendez avant d'extraire des stratégies. Extrayez quand vous voyez le troisième cas similaire apparaître — pas avant.

La règle empirique : refactorisez vers Strategy, ne commencez pas avec Strategy. Laissez le code vous montrer qu'il en a besoin.


Strategy vs Factory : quelle différence ?

Les deux patterns utilisent des interfaces et des classes concrètes, et il est facile de les confondre.

FactoryStrategy
RôleCrée des objetsExécute des algorithmes
Question"Quel objet instancier ?""Quel comportement adopter ?"
RésultatUn objetUn calcul ou une action
CombinaisonUne Factory peut créer des StrategiesUne Strategy peut utiliser une Factory

En pratique, ils se complètent souvent : une Factory choisit et instancie la bonne Strategy, et le Context l'exécute.


Résumé

Sans StrategyAvec Strategy
Un if/else if qui grossit à chaque demandeUne classe par algorithme, isolée et testable
Modifier un cas risque de casser les autresModifier une stratégie n'affecte que sa classe
Tests difficiles à isolerTests unitaires simples et ciblés
Violation du principe Open/ClosedExtension sans modification du Context
Code client couplé aux détails des algorithmesContext couplé uniquement à l'interface

Strategy, c'est l'art de transformer une condition en objet. Quand votre if/else devient une famille d'algorithmes qui évoluent indépendamment, donnez-leur chacun leur propre classe. Votre code métier n'a plus à connaître les détails — il choisit une stratégie, il l'exécute, il passe à autre chose.