Design Pattern Strategy
Un comportement adapté à chaque contexte
Introduction
Le design pattern Strategy est un pattern de conception comportemental, dont l’objectif est de définir plusieurs algorithmes interchangeables, tout en permettant à une application de choisir celui qu’elle souhaite utiliser.
Lorsqu’une application doit exécuter un algorithme dont le comportement varie selon certaines conditions, une première solution consiste souvent à utiliser des structures itératives if ou switch. Cette approche peut convenir lorsque le nombre de comportements est limité. Mais elle peut devenir plus difficile à maintenir, lorsque de nouvelles variantes doivent régulièrement être ajoutées.
Le design pattern Strategy propose d’encapsuler chaque comportement dans une classe distincte. Toutes les stratégies implémentent une interface commune. Le polymorphisme par interface permet de les rendre interchangeables : il est possible de passer d'une stratégie à l'autre.
Problématique
Le 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 qui calcule une remise en fonction du type de client. Trois catégories de clients sont prises en charge :
-
Les particuliers qui bénéficient d’une remise de 5 %.
-
Les professionnels qui bénéficient d’une remise de 10 %.
-
Les clients premium qui 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 de modifier cette classe. Elle cumule également plusieurs responsabilités : sélectionner la règle et exécuter le calcul.
Conception
Ce diagramme présente les classes permettant d'implémenter le design pattern Strategy :
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 l'exécution du calcul de la remise en appelant la 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é, ce qui justifie davantage l'utilisation de ce design pattern.
Implémentation
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 dans 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 qui correspond 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 favoriser le découplage entre le contexte et les différents algorithmes.
-
De mieux respecter les principes SOLID SRP (Single Responsibility Principle) et OCP (Open/Closed Principle) .
-
De faciliter les tests unitaires, chaque stratégie pouvant être testée indépendamment. Voici un exemple :
[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 structure conditionnelle 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.
Les design patterns Strategy et State
Les design patterns Strategy et State permettent de déterminer du code à exécuter en fonction d'un critère. Dans les deux cas, une classe délègue un comportement à une autre classe, par l’intermédiaire d’une interface commune.
Leur intention est cependant différente. Avec le pattern Strategy, une stratégie est sélectionnée par le code client en fonction du contexte. Les différentes stratégies sont interchangeables et ne représentent pas l’état de l’objet qui les utilise.
Avec le pattern State, le comportement d’un objet dépend de son état courant. Les transitions entre les états font partie du cycle de vie de cet objet, et peuvent être déclenchées par les états eux-mêmes.
Dans notre exemple, les différentes méthodes de calcul d’une remise constituent des stratégies, alors que les passages successifs d'un état à l'autre d’une commande (par exemple : créée, payée, expédiée ou livrée) sont pris en charge par le design pattern State.
