Aller au contenu

Strategy

Design Pattern Strategy

Un comportement adapté à chaque contexte

Introduction

Le terme Strategy provient du fait que chaque classe représente une stratégie de résolution d'un même problème. Le contexte n’a pas besoin de connaître les détails de l’algorithme utilisé : il sélectionne la stratégie appropriée puis lui délègue le traitement.

Dans un logiciel, il est fréquent qu’un même traitement puisse être exécuté de plusieurs manières selon le contexte.

Par exemple, le calcul d’une remise peut dépendre du type de client, le prix d’une livraison peut varier selon le transporteur ou encore un paiement peut être réalisé par carte bancaire, virement ou portefeuille électronique. Une première approche consiste à utiliser une succession de conditions :

if (customer.Type == CustomerType.Individual)
{
    // Calcul de la remise pour un particulier
}
else if (customer.Type == CustomerType.Professional)
{
    // Calcul de la remise pour un professionnel
}
else if (customer.Type == CustomerType.Premium)
{
    // Calcul de la remise pour un client premium
}
else
    throw new ArgumentOutOfRangeException(
        nameof(customer.Type),
        customer.Type,
        "Le type de client n'est pas pris en charge.");

Cette implémentation peut convenir lorsqu’il existe peu de variantes. Cependant, elle devient progressivement plus difficile à faire évoluer lorsque de nouveaux comportements apparaissent.

Le design pattern Strategy permet d'isoler chaque variante d'un traitement dans une classe dédiée. Dans notre exemple, chaque stratégie implémente une règle de calcul de remise différente selon le type de client.

Présentation

Le design pattern Strategy permet d’encapsuler plusieurs comportements interchangeables derrière un contrat commun. Au lieu de regrouper toutes les variantes d’un traitement dans une même classe et de les sélectionner à l’aide de conditions, chaque comportement est placé dans une stratégie dédiée. Ainsi, le code devient plus modulaire, plus facile à tester et plus simple à faire évoluer.

D'un point de vue implémentation, chaque algorithme est encapsulé dans une classe distincte et respecte un contrat commun. Le code utilisant ces stratégies ne dépend donc plus directement de leurs implémentations : il manipule uniquement leur abstraction. Le design pattern Strategy repose généralement sur trois éléments :

  • Une interface définissant le contrat commun des stratégies

  • Plusieurs stratégies concrètes implémentant chacune un algorithme

  • Un contexte sélectionnant une stratégie puis lui déléguant le traitement

L’objectif est de pouvoir modifier ou ajouter un comportement sans modifier le code qui l’utilise.

Cas d’utilisation

Ce design pattern Strategy est particulièrement utile lorsque plusieurs algorithmes répondent au même besoin et que leurs règles sont amenées à évoluer indépendamment.

Prenons l’exemple d’une application de vente devant calculer une remise en fonction du type de client. Trois catégories de clients sont prises en charge :

  • Les particuliers bénéficient d’une remise de 5 %

  • Les professionnels bénéficient d’une remise de 10 %

  • Les clients premium bénéficient d’une remise de 20 %

Sans le design pattern Strategy, le calcul pourrait être implémenté de la manière suivante :

public enum CustomerType
{
    Individual = 1,
    Professional = 2,
    Premium = 3
}

public class DiscountService
{
    public decimal CalculateDiscount(
        CustomerType customerType,
        decimal amount)
    {
        return customerType switch
        {
            CustomerType.Individual => amount * 0.05m,
            CustomerType.Professional => amount * 0.10m,
            CustomerType.Premium => amount * 0.20m,
            _ => throw new ArgumentOutOfRangeException(
                     nameof(customerType),
                     customerType,
                     "Le type de client n'est pas pris en charge.")
        };
    }
}

Cette implémentation est simple, mais la classe DiscountService connaît toutes les règles de calcul. Chaque ajout ou modification d’une stratégie nécessite donc de modifier cette classe. Elle cumule également plusieurs responsabilités : sélectionner la règle et effectuer le calcul.

Conception

Ce diagramme présente les classes permettant d'implémenter le design pattern Strategy :

Design Pattern Strategy : conception

La classe DiscountCalculator constitue le contexte. Elle reçoit l’ensemble des stratégies disponibles par injection de dépendances, sélectionne celle qui correspond au type de client, puis lui délègue le calcul de la remise en appelant sa méthode Calculate.

L'exemple présenté ici est volontairement simple afin d'illustrer le fonctionnement du design pattern Strategy. Dans une application réelle, chaque stratégie met généralement en œuvre une logique métier plus complexe, propre au type de client concerné (avec ajout de méthodes), ce qui justifie l'utilisation de ce design pattern.

Implémentation

Définir le contrat des stratégies

Nous commençons par créer une interface représentant une stratégie de calcul de remise :

public interface IDiscountStrategy
{
    CustomerType CustomerType { get; }

    decimal Calculate(decimal amount);
}

La propriété CustomerType ne représente pas l’algorithme lui-même. Elle sert à associer chaque stratégie au type de client pour lequel elle doit être sélectionnée.

Toutes les stratégies concrètes doivent implémenter les membres définis par cette interface :

public class IndividualDiscountStrategy : IDiscountStrategy
{
    public CustomerType CustomerType => CustomerType.Individual;

    public decimal Calculate(decimal amount)
        => amount * 0.05m;
}
public class ProfessionalDiscountStrategy : IDiscountStrategy
{
    public CustomerType CustomerType => CustomerType.Professional;

    public decimal Calculate(decimal amount)
        => amount * 0.1m;
}
public class PremiumDiscountStrategy : IDiscountStrategy
{
    public CustomerType CustomerType => CustomerType.Premium;

    public decimal Calculate(decimal amount)
        => amount * 0.2m;
}

Chaque règle de calcul est désormais isolée dans une classe dédiée.

Enregistrer et sélectionner les stratégies avec l’injection de dépendances

Chaque implémentation de l'interface IDiscountStrategy est enregistrée dans le conteneur d'injection de dépendances avec le même contrat :

services.AddScoped<IDiscountStrategy, IndividualDiscountStrategy>();
services.AddScoped<IDiscountStrategy, ProfessionalDiscountStrategy>();
services.AddScoped<IDiscountStrategy, PremiumDiscountStrategy>();

Comme plusieurs implémentations sont associées à la même interface, elles peuvent être injectées sous la forme d’une collection IEnumerable\<IDiscountStrategy>. Le contexte peut ensuite parcourir ces stratégies, ou les organiser dans un dictionnaire, afin de sélectionner celle correspondant au type de client.

public class DiscountCalculator
{
    private readonly IReadOnlyDictionary<CustomerType, IDiscountStrategy>
        _strategies;

    public DiscountCalculator(IEnumerable<IDiscountStrategy> strategies)
    {
        _strategies = strategies.ToDictionary(strategy => strategy.CustomerType);
    }

    public decimal Calculate(CustomerType customerType, decimal amount)
    {
        if (!_strategies.TryGetValue(customerType, out var strategy))
        {
            throw new ArgumentOutOfRangeException(
                nameof(customerType),
                customerType,
                "Aucune stratégie de remise n'est associée à ce type de client.");
        }

        return strategy.Calculate(amount);
    }
}

Avantages

Le design pattern Strategy permet notamment :

  • D’isoler chaque algorithme dans une classe dédiée

  • De faciliter les tests unitaires, chaque stratégie pouvant être testée indépendamment

  • De favoriser le découplage entre le contexte et les différents algorithmes

  • De mieux respecter les principes SOLID de responsabilité unique (SRP) et d'ouverture / fermeture (OCP)

Par exemple, la stratégie appliquée aux clients premium peut être testée indépendamment :

[Fact]
public void Calculate_ShouldReturnTwentyPercentDiscount()
{
    var strategy = new PremiumDiscountStrategy();
    decimal discount = strategy.Calculate(1_000m);
    Assert.Equal(200m, discount);
}

Inconvénients et limites

Le design pattern Strategy ne doit pas être appliqué systématiquement. Pour un traitement très simple ne possédant que deux variantes stables, une expression switch peut être plus lisible et suffisante.

L’utilisation de ce design pattern entraîne également la création de plusieurs interfaces et classes. Cette abstraction supplémentaire n’est pertinente que lorsque les comportements sont susceptibles d’évoluer, d’être remplacés ou d’être testés séparément.