Design d’un moteur de Mapping en .NET – Avancée en orienté objet en C♯
Dans l’article précédent, nous avons conçu une API pour définir des règles de mapping de notre moteur. Cette API est en partie orientée objet car l’assemblage des règles n’y est pas proposée et repose par exemple sur une requête LINQ.
L’objectif est désormais d’intégrer à l’API cet assemblage, en proposant un niveau plus haut, un concept chapeau correspondant à l’ensemble des règles. Le mode opératoire sera le même que pour les articles précédents : avancer étape par étape, faire un bilan à la fin de chacune et tenter d’adresser les inconvénients détectés à l’étape suivante.
🏷 Sommaire
Encapsulation des règles
Nous en sommes restés là à la fin de l’article précédent :
using static EquipmentFormatter.Criteria;
using static EquipmentFormatter.RuleBuilder;
// […]
public static string ComputeLabel(Equipment equipment, Variation variation)
{
var label = equipment.Label.Trim();
return ComputeLabel(variation, label);
}
private static string ComputeLabel(Variation variation, string label) =>
new[]
{
When(SchemaIs(7407)).ExchangeWith("Nombre de cylindres"),
// […]
When(SchemaIs(14104)).Append(" : profil"),
}
.Where(rule => rule.IsSatisfiedBy(variation))
.Select(rule => rule.ApplyOn(label))
.Take(1)
.DefaultIfEmpty(label)
.First();
La logique de sélection de la règle repose sur une requête LINQ opérant sur l’ensemble des règles spécifiées. La transition vers un design orienté objet consiste à encapsuler dans un objet à la fois ses règles et le comportement associé, exposé sous la forme d’un contrat. En élevant le niveau d’abstraction de la sorte, la manière de stocker les règles est masquée, considérée comme un détail d’implémentation, ce qui permet d’en varier au besoin comme nous allons le voir.
☝️ Note : cette stratégie constitue la règle n°4 “First class collections” des Object Calisthenics, ensemble de neuf règles permettant de s’exercer à écrire un meilleur code orienté objet.
Rules
tout en un
Commençons par définir le contrat de ce nouvel objet, en partant du code client. Passons les détails de refactoring. Nous arrivons à cette première ébauche :
private static readonly Rules Rules = new Rules(
When(SchemaIs(7407)).ExchangeWith("Nombre de cylindres"),
// […]
When(SchemaIs(14104)).Append(" : profil"),
);
public static string ComputeLabel(Equipment equipment, Variation variation) =>
Rules.Apply(variation, equipment.Label.Trim());
public class Rules
{
private readonly IReadOnlyCollection<Rule> rules;
public Rules(params Rule[] rules)
{
this.rules = rules.ToImmutableList();
}
public string Apply(Variation variation, string label) =>
rules.Where(rule => rule.IsSatisfiedBy(variation))
.Select(rule => rule.ApplyOn(label))
.Take(1)
.DefaultIfEmpty(label)
.First();
}
Quelques remarques d’emblée :
- L’objet est construit à partir d’un ensemble de règles fixes, sans offrir la possibilité d’ajouter une règle après l’autre à la manière d’une
ICollection<Rule>
.
Nous verrons plus loin comment adapter notre
RuleBuilder
pour adresser cette problématique. Pour l’heure, nous n’avons pas besoin de cette fonctionnalité. Inutile de complexifier le code pour un hypothétique usage futur, et restons dans l’esprit du principe YAGNI. - Nous avons un objet de type
Rules
offrant une méthodestring Apply(Variation variation, string label)
.
Il ne s’agit donc plus de la même interface qu’une règle
Rule { boolean IsSatisfiedBy(Variation); string ApplyOn(string label); }
et en cela, ce n’est pas une application du pattern Composite. L’interface deRules
est néanmoins très proche de celle deRule
, gardant la même sémantique. C’est là le problème : parler deRules
à appliquer sur uneVariation
et unestring label
devient bancal.
👉 Il nous faut une meilleure abstraction !
Décomposition du process
Il me semble difficile de trouver une abstraction mariant Variation
et label
dans une même méthode. On va donc les séparer, et traiter ces paramètres l’un après l’autre :
- Le
label
est l’objet de l’opération qui est appliquée par la règle satisfaite.
→ On va isoler cette fonctionnalité au sein d’une interfaceIOperation { string ApplyOn(string label); }
implémentée parRule
, ce qui nous permet d’appliquer le principe SOLID de ségrégation des interfaces (ISP).☝️ Notons que l’on peut procéder ainsi car nos deux fonctionnalités (
IsSatisfiedBy
etApplyOn
) sont indépendantes. Si l’on appelaitIsSatisfiedBy
depuisApplyOn
et si l’on émettait une exception dans le cas oùIsSatisfiedBy
renvoiefalse
, on aurait un objetIOperation
avec un comportement limite ne figurant pas dans l’interface 💣. Alors, cela serait un cas de violation du principe SOLID de substitution de Liskov (LSP). - La
Variation
sert à sélectionner la règle satisfaite par ce critère.
→ Notre objetRules
va donc suivre l’interfaceIOperation SelectOperationAdaptedTo(Variation variation);
. Ainsi, même lorsqu’aucune règle n’est satisfaite, on va renvoyer une opération par défaut plutôt quenull
, ce qui est désigné par le pattern Null object. Cette règle est toujours satisfaite et renvoie lelabel
en entrée ; on peut la créer ainsi :new Rule(_ => true, label => label)
, que l’on place dans la méthode factoryRule.Default(operation).☝️ Notons au passage la convention de nommage utilisant
_
pour indiquer un paramètre inutilisé.
Cela donne le code suivant :
public static string ComputeLabel(Equipment equipment, Variation variation) =>
Rules.SelectOperationAdaptedTo(variation)
.ApplyOn(equipment.Label.Trim());
// […]
public class Rule : IOperation
{
public static Rule Default(Func<string, string> operation) =>
new Rule(_ => true, operation);
// […]
}
public class Rules
{
private readonly IReadOnlyCollection<Rule> rules;
public Rules(params Rule[] rules)
{
this.rules = rules.ToImmutableList();
}
public IOperation SelectOperationAdaptedTo(Variation variation) =>
rules.Where(rule => rule.IsSatisfiedBy(variation))
.DefaultIfEmpty(Rule.Default(label => label))
.First();
}
Bilan :
- ✔️ La sémantique et l’API sont meilleurs.
- ✔️ Le stockage des règles reste délégué à un objet de manière masquée à un niveau de détails plus bas.
- ❌ Il reste une requête LINQ, même si elle est désormais plus simple. Or, on n’a besoin ni d’une collection bas niveau, ni d’une requête opérant sur cette collection. On peut encoder tout cela directement dans l’objet.
💡 Code source sur le GitHub de SOAT ici.
Chaîne de règles
Si chaque règle connaît l’éventuelle règle suivante, elle pourra lui transmettre le relai dans le cas où le critère ne la satisfait pas. Il s’agit donc d’une variante du pattern Chain-of-responsibility, lui-même reposant sur le principe de continuation, c’est-à-dire un flux de contrôle intrinsèque (encodé dans les objets manipulés) plutôt qu’extrinsèque (géré dans la requête LINQ portant sur les objets).
RuleChain
Afin de décorréler les deux concepts Règle et Chaîne de règles, créons une nouvelle classe RuleChain
représentant à la fois un maillon de la chaîne, ce maillon connaissant le maillon suivant, et la chaîne elle-même depuis ce maillon, de successeur en successeur. Le maillon encapsule une règle mais, contrairement au pattern Decorator, l’interface change pour offrir une unique méthode Apply(Variation variation, string label)
contenant la logique de succession des règles :
public sealed class RuleChain
{
private Rule Rule { get; }
private RuleChain Next { get; }
public RuleChain(Rule rule, RuleChain next)
{
Rule = rule;
Next = next;
}
public IOperation SelectOperationAdaptedTo(Variation variation) =>
Rule.IsSatisfiedBy(variation)
? (IOperation) Rule
: Next.SelectOperationAdaptedTo(variation);
}
L’implémentation actuelle présente l’avantage d’offrir des objets immuables. Par contre, elle comporte aussi deux inconvénients :
❌ Inconvénient 1 : pour terminer la chaîne, il faut passer un maillon
null
.
On peut résoudre ce problème en rendant optionnel next
: on peut lui donner le type Option<RuleChain>
, Option
venant du package NuGet LanguageExt. Alors, on ne peut plus faire Next.Apply()
. À la place, on va utiliser la méthode Match(Some, None)
qui simule le pattern matching exhaustif du F♯. Dans le cas du None
, on va renvoyer une règle par défaut :
using LanguageExt;
using static LanguageExt.Prelude;
public sealed class RuleChain
{
private Rule Rule { get; }
private Option<RuleChain> Next { get; }
public RuleChain(Rule rule, RuleChain next)
{
Rule = rule;
Next = Optional(next);
}
public IOperation SelectOperationAdaptedTo(Variation variation) =>
Rule.IsSatisfiedBy(variation)
? (IOperation) Rule
: Next.Match(
Some: chain => chain.SelectOperationAdaptedTo(variation),
None: () => Rule.Default(label => label));
}
❌ Inconvénient 2 : lorsque l’on construit la chaîne, cela induit une inélégante imbrication de règles.
var chain =
new RuleChain(Rule1,
new RuleChain(Rule2,
new RuleChain(Rule3,
null);
Imaginez ce que cela donne avec nos 21 règles ! Ce nesting se résout normalement en construisant le maillon en deux temps : d’abord on crée le maillon seul, sans successeur, puis on lui ajoute un successeur :
var chain =
new RuleChain(Rule1)
.ContinueWith(new RuleChain(Rule2))
.ContinueWith(new RuleChain(Rule3));
On retrouve la syntaxe du Fluent Builder. Comme on en a déjà un pour les règles, il serait plus élégant de faire du « 2 en 1 ».
RuleChainBuilder
Commençons par définir l’API. Afin d’avoir une utilisation comme « sur des rails », l’API s’articule autour des éléments suivants :
Détails :
Case()
crée un builder de typeTakingCriteriaOrEnding
exposé sous l’interface simplifiéeITakingCriteria
afin de bloquerCase().Else()
et forcer à avoir au moins une règle définie à partir duWhen(criteria)
.When(criteria)
sert à stocker le critère de la règle à construire et renvoie un nouveau builder de typeTakingOperation
. Cela permet d’éviter de faire deux.When()
de suite, ce qui n’a pas de sens.TakingOperation
expose trois méthodes, chacune associée à une opération (Append
,ExchangeWith
ouReplace
) et renvoyant une nouvelle instance deTakingCriteriaOrEnding
afin d’ajouter d’autres règles ou de clore la procédure de build.TakingCriteriaOrEnding
expose deux méthodes :
• CommeITakingCriteria
, on a la méthodeWhen(criteria)
pour ajouter une règle.
• On a également la méthodeElse(operation)
qui sert à clore la procédure de build en renvoyant la chaîne de règles se terminant par une règle par défaut appliquant l’opération spécifiée.- Chaque méthode renvoie une nouvelle instance de builder afin de bénéficier des avantages de l’immuabilité.
📣 ❝ Ça semble trop cool ! Assez de blabla ! On veut voir le code ! ❞
OK ! Mais en trois temps, pour que cela soit digeste et présentable dans un article. 😅
public static class RuleChainBuilder
{
public static ITakingCriteria Case() =>
new TakingCriteriaOrEnding(ImmutableStack<Rule>.Empty);
public interface ITakingCriteria
{
TakingOperation When(Criteria criteria);
}
public sealed class TakingCriteriaOrEnding : ITakingCriteria { /* […] */ }
public sealed class TakingOperation { /* […] */ }
}
Petites particularités à noter :
Case()
initialise le builderTakingCriteriaOrEnding
avec une pile vide qui va servir à empiler les règles que l’on va dépiler pour construire la chaîne de règles. La pile est de typeImmutableStack
pour que notre builder soit immuable.- L’interface
ITakingCriteria
et les deux classesTakingCriteriaOrEnding
etTakingOperation
doivent avoir le même niveau de visibilité queRuleChainBuilder
, icipublic
. Cependant, elles sont imbriquées dansRuleChainBuilder
pour indiquer qu’il s’agit de rouages internes à ne pas utiliser directement.
TakingCriteriaOrEnding
public sealed class TakingCriteriaOrEnding : ITakingCriteria
{
private ImmutableStack<Rule> Rules { get; }
public TakingCriteriaOrEnding(ImmutableStack<Rule> rules)
{
Rules = rules;
}
public TakingOperation When(Criteria criteria) =>
new TakingOperation(criteria, Rules);
public RuleChain Else(Func<string, string> operation) =>
Rules.Aggregate(
seed: EndRuleChainWith(operation),
(chain, rule) => new RuleChain(rule, chain));
private static RuleChain EndRuleChainWith(Func<string, string> operation) =>
new RuleChain(Rule.Default(operation), null);
}
La partie intéressante de cette classe se trouve dans la méthode Else(operation)
:
- On construit la chaîne depuis le dernier jusqu’au premier maillon en parcourant la pile de règles (dans son sens spécifique LIFO).
- La construction s’effectue via un
Aggregate
qui combine le maillon courant avec la règle courante, en partant du maillon de fin renvoyé parEndRuleChainWith()
, qui encapsule une règle par défaut effectuant l’opération spécifiée.
TakingOperation
public sealed class TakingOperation
{
private Criteria Criteria { get; }
private ImmutableStack<Rule> Rules { get; }
public TakingOperation(Criteria criteria, ImmutableStack<Rule> rules)
{
Criteria = criteria;
Rules = rules;
}
public TakingCriteriaOrEnding ExchangeWith(string value) =>
BuildRule(_ => value);
public TakingCriteriaOrEnding Replace(string part, string by) =>
BuildRule(label => label.Replace(part, by));
public TakingCriteriaOrEnding Append(string value) =>
BuildRule(label => label + value);
private TakingCriteriaOrEnding BuildRule(Func<string, string> operation) =>
new TakingCriteriaOrEnding(Rules.Push(new Rule(Criteria, operation)));
}
Cette classe est très similaire à notre RuleBuilder
précédent. Une particularité notable : Rules.Push(new Rule(Criteria, operation))
qui sert à empiler la règle dans une copie de la pile, elle-même passée au builder créé et renvoyé.
Utilisation du builder
La construction de la chaîne grâce au builder reprend la même logique qu’au chapitre précédent, cette fois-ci en combinant toutes les règles en une chaîne globale de règles exclusives, se terminant par la règle par défaut effectuant une opération passe-plat (cf. Else(label => label)
) :
private static readonly RuleChain Rules =
Case()
.When(SchemaIs(7407)).ExchangeWith("Nombre de cylindres")
.When(SchemaIs(15304)).ExchangeWith("Puissance (ch)")
.When(SchemaIs(15305)).ExchangeWith("Régime de puissance maxi (tr/mn)")
.When(SchemaIsIn(23502, 24002)).Replace("an(s) / km", by: ": durée (ans)")
.When(SchemaIsIn(23503, 24003)).Replace("an(s) / km", by: ": kilométrage")
/* […] */
.When(SchemaIs(14103)).Append(" : largeur")
.When(SchemaIs(14104)).Append(" : profil")
.Else(label => label);
Bilan :
- ✔️ La chaîne de règles ne repose plus sur une collection bas niveau.
On se sert quand même d’une pile (Stack) dans le builder mais c’est transitoire, le temps de construire la chaîne de règles. C’est un compromis pour offrir une API plus friendly. 😅
- ❌ En faisant du sur-mesure, on est tombé dans l’excès, le « sur-spécifique » : on s’est fermé des portes permettant d’étendre les fonctionnalités sans toucher au code de l’API. De plus, c’est dommage de ne pas pouvoir réutiliser cette chaîne avec son builder.
- 👉 Et bien, puisque l’on a une sémantique générique (
Rule(Chain)
,Criteria
,Operation
), rendons également le code générique ! 😁
💡 Code source sur le GitHub de SOAT ici.
Chaîne de règles génériques
Changement de sémantique
Nous allons légèrement changer la sémantique :
- En entrée du
When()
, nous parlons désormais de Condition plutôt que de Critère, avec une condition portant sur un critère générique :Condition<TCriteria>
. - Les opérations s’effectuent sur une donnée générique
TData
. Nous nous contraindrons au fait que le type en sortie soit le même que celui en entrée :IOperation<TData> { TData ApplyOn(TData data); }
. Cela présente les avantages suivants :
• Avoir un seul type génériqueTData
plutôt que deux (TDataIn
etTDataOut
), ce qui simplifie l’écriture et la lecture du code.
• Obtenir la Closure of Operations, qui permet de décomposer une grosse opération en plusieurs opérations plus petites, comme on le fait ici avecComputeLabel()
, dont le cœur consiste en une opérationLabel -> Label
. - Le builder générique supporte toute opération de type
TData -> TData
, en proposant une seule et unique méthodeThen(Func<TData, TData> operation)
.
Implémentation des règles génériques
Tout ce qui tourne autour des règles devient générique selon TCriteria
et TData
:
class Rule<TCriteria, TData> : IOperation<TData>
class RuleChain<TCriteria, TData>
class RuleChainBuilder<TCriteria, TData>
ou types internes[I]Taking[CriteriaOrEnding|Operation]<TCriteria, TData>
- La méthode statique d’entrée
Case()
sera renomméeGiven<TCriteria, TData>
pour faire plus naturelle et placée dans une classe statique non générique afin de pouvoir être importée statiquement.
Je vous épargne les détails d’implémentation car le code n’est pas difficile à rendre générique : il suffit de remplacer Variation
par TCriteria
et string
par TData
, puis de déclarer ces types génériques au niveau des classes ou interfaces parents, à l’exception de la méthode statique Given<TCriteria, TData>()
qui n’est pas placée dans la classe RuleChainBuilder<TCriteria, TData>
mais dans une autre classe non générique de même nom : RuleChainBuilder
.
Utilisation des règles génériques
Côté EquipmentMapper
, on est de nouveau dans le spécifique :
- Le Critère est de type
Variation
. Les méthodes Factory associées (LocationIs
,SchemaIs
…) sont placées dans une nouvelle classeVariationCondition
. - Les Opérations portent une Donnée qui est le Label, de type
string
.Append
,ExchangeWith
etReplace
redeviennent des méthodes Factory, placées dans une classeLabelOperation
.
Une fois ceci mis en place, le code de EquipmentMapper
ne change quasiment pas :
private static readonly RuleChain<Variation, string> Rules =
Given<Variation, string>()
.When(SchemaIs(7407)).Then(ExchangeWith("Nombre de cylindres"))
.When(SchemaIs(15304)).Then(ExchangeWith("Puissance (ch)"))
.When(SchemaIs(15305)).Then(ExchangeWith("Régime de puissance maxi (tr/mn)"))
.When(SchemaIsIn(23502, 24002)).Then(Replace("an(s) / km", by: ": durée (ans)"))
.When(SchemaIsIn(23503, 24003)).Then(Replace("an(s) / km", by: ": kilométrage"))
/* […] */
.When(SchemaIs(14103)).Then(Append(" : largeur"))
.When(SchemaIs(14104)).Then(Append(" : profil"))
.Else(label => label);
public static string ComputeLabel(Equipment equipment, Variation variation) =>
Rules.SelectOperationAdaptedTo(variation)
.ApplyOn(equipment.Label.Trim());
Bilan :
- ✔️ L’API est générique, acceptant tout type de critères et d’opérations.
- ❌ L’implémentation est plus difficile à lire, parsemée de types génériques
TCriteria
etTData
, mais on peut difficilement faire autrement vu que C♯ ne dispose pas d’une inférence de types poussée comme en F♯. - ❌ L’API est un peu plus verbeuse.
💡 Code source sur le GitHub de SOAT ici.
Version alternative
Nous pouvons améliorer l’API pour la rendre plus succincte et plus agréable à utiliser. L’idée de base est de fournir la Variation et le Label directement en entrée de Given
, ce qui permet :
- L’inférence des types
TCriteria
,TData
, - Le calcul du Label directement en sortie du
Else
.
public static string ComputeLabel(Equipment equipment, Variation variation) =>
Given(criteria: variation, data: equipment.Label.Trim())
.When(SchemaIs(7407)).Then(ExchangeWith("Nombre de cylindres"))
.When(SchemaIs(15304)).Then(ExchangeWith("Puissance (ch)"))
.When(SchemaIs(15305)).Then(ExchangeWith("Régime de puissance maxi (tr/mn)"))
.When(SchemaIsIn(23502, 24002)).Then(Replace("an(s) / km", by: ": durée (ans)"))
.When(SchemaIsIn(23503, 24003)).Then(Replace("an(s) / km", by: ": kilométrage"))
/* […] */
.When(SchemaIs(14103)).Then(Append(" : largeur"))
.When(SchemaIs(14104)).Then(Append(" : profil"))
.Else(label => label);
Pour l’implémenter, le principe est :
- De passer le Critère et la Donnée à chaque objet Builder tout au long de la chaîne de construction,
- D’enchaîner la construction de la chaîne de règles avec son exécution via
SelectOperationAdaptedTo(Criteria).ApplyOn(Data)
.
Même si c’est un peu fastidieux à coder, cela se fait facilement. Nous nous passerons donc des détails.
Pour éviter de se trimballer le Critère et la Donnée, on peut opter pour un Builder mutable, où ces champs seront initialisés une bonne fois pour toutes. C’est un compromis possible, favorisant une implémentation plus simple au détriment d’un usage moins sécurisé. Il devient alors intéressant de voir comment le coder en continuant de garantir la syntaxe “When Then (When Then…) Else”.
Le principe consiste à reprendre la ségrégation en trois interfaces ITakingCondition
, ITakingOperation
, IEnding
, cette fois-ci implémentées par la même classe. Nous devons d’abord sortir les interfaces de la classe RuleChainBuilder<TCriteria, TData>
afin de pouvoir indiquer que la classe implémente ces interfaces. Cela nous incite à les renommer en IRuleChainBuilder*
et à les rendre génériques. Par ailleurs, comme on a deux choix possibles en sortie du Then (enchaîner à nouveau sur un When Then ou terminer par un Else), on doit créer une quatrième interface « chapeau » IRuleChainBuilderTakingConditionOrEnding
:
public interface IRuleChainBuilderEnding<TData>
{
TData Else(Func<TData, TData> operation);
}
public interface IRuleChainBuilderTakingCondition<TCriteria, TData>
{
IRuleChainBuilderTakingOperation<TCriteria, TData> When(Condition<TCriteria> condition);
}
public interface IRuleChainBuilderTakingConditionOrEnding<TCriteria, TData> :
IRuleChainBuilderTakingCondition<TCriteria, TData>,
IRuleChainBuilderEnding<TData> {}
public interface IRuleChainBuilderTakingOperation<TCriteria, TData>
{
IRuleChainBuilderTakingConditionOrEnding<TCriteria, TData> Then(Func<TData, TData> operation);
}
On peut implémenter le Builder ainsi :
public sealed class RuleChainBuilder<TCriteria, TData> :
IRuleChainBuilderTakingConditionOrEnding<TCriteria, TData>,
IRuleChainBuilderTakingOperation<TCriteria, TData>
{
private Condition<TCriteria> Condition { get; set; }
private TCriteria Criteria { get; }
private TData Data { get; }
private Stack<Rule<TCriteria, TData>> Rules { get; } = new Stack<Rule<TCriteria, TData>>();
public RuleChainBuilder(TCriteria criteria, TData data)
{
Criteria = criteria;
Data = data;
}
public IRuleChainBuilderTakingOperation<TCriteria, TData> When(Condition<TCriteria> condition)
{
Condition = condition;
return this;
}
public IRuleChainBuilderTakingConditionOrEnding<TCriteria, TData> Then(Func<TData, TData> operation)
{
Rules.Push(new Rule<TCriteria, TData>(Condition, operation));
return this;
}
public TData Else(Func<TData, TData> operation) /* […] */
}
Le point d’entrée Given est quasiment identique :
public static class RuleChainBuilder
{
public static IRuleChainBuilderTakingCondition<TCriteria, TData> Given<TCriteria, TData>(TCriteria criteria, TData data) =>
new RuleChainBuilder<TCriteria, TData>(criteria, data);
}
💡 Code source sur le GitHub de SOAT ici.
Bilan :
- ✔️ La syntaxe devient plus concise en se limitant désormais à Given When Then Else.
- ✔️ Ce côté « langage naturel », simple variante de la syntaxe Gherkin assez connue, facilite non seulement l’apprentissage de l’API, mais aussi la lecture du code produit à partir de cette API. D’ailleurs, je trouve que l’on aboutit à la représentation la plus condensée et la plus lisible de la table de décision.
- ✔️ Les rouages internes à base de Règles sont masqués à l’utilisateur.
- ❌ La chaîne de Règles est reconstruite à chaque fois plutôt que d’être stockée dans une variable statique.
- ❌ L’implémentation sous la forme du Builder immuable est encore plus « chargée ».
- ➖ La version « mutable » peut alors représenter une alternative intéressante pour simplifier l’implémentation, le risque de mauvaise utilisation de l’API étant acceptable en C♯, où l’on est habitué à manipuler des objets qui mutent.
☝️ Recommandation : dans tous les cas, la version générique de l’API est à réserver aux cas où l’on est sûr de la réutiliser, ou si l’on envisage de la partager avec d’autres systèmes, par exemple en l’exportant dans une librairie.
Conclusion
Nous avons facilement réussi à intégrer à notre API un niveau « chapeau » pour encapsuler les règles. Cela a été une autre affaire pour d’une part remplacer la requête LINQ en l’encodant dans une chaîne de règles s’inspirant du pattern Chain of responsibility, d’autre part continuer à proposer une API user-friendly pour définir cette chaîne, ce qui a été possible en adaptant le précédent Builder pour proposer un DSL similaire au Gerkhin. Nous avons poussé l’expérimentation plus loin en rendant générique l’API, ceci de deux manières, la première étant moins user-friendly mais ayant une implémentation plus robuste et inversement pour la deuxième.
Par rapport à l’article précédent, le code obtenu ici est plus difficile à mettre en place. Cela requiert une plus grande habilité en OOP mais reste néanmoins faisable. Est-ce pour autant envisageable de pousser ainsi le design de code sur vos projets/produits ? Pour y répondre, il faut garder en tête l’objectif : proposer une API à la fois complète et user-friendly de manière minimiser les risques de mauvaises usages de l’API, et donc de bugs. Le revers de la médaille est la difficulté à concevoir une telle API et encore plus à l’implémenter. Le rapport bénéfice sur coût dépend donc de vos projets/produits : plus ils comportent de complexité métier, plus cela vaut le coup de pousser la modélisation du domaine afin de faciliter l’implémentation des cas d’utilisation.
Autre facteur pouvant faire pencher la balance : l’API sera-t-elle partagée, par exemple dans un package NuGet ? Le succès d’une librairie se fait aussi sur la qualité de son API. Prenons l’exemple d’AutoMapper, librairie de mapping entre objets, typiquement entre les entités d’un ORM et les modèles du domaine, entre les modèles et les DTOs, entre modèles internes et ceux d’une API externe. Même si cette librairie propose des convention-based mappings, tous les cas particuliers peuvent être définis de manière précise et élégante. C’est à mes yeux un exemple d’API réussie. On retrouve d’ailleurs les mêmes ingrédients : Fluent API, utilisation de lambdas, comme on peut le constater dans l’exemple suivant :
var config = new MapperConfiguration(cfg =>
cfg.CreateMap<Employee, EmployeeDTO>()
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
.ForMember(dest => dest.Dept, opt => opt.MapFrom(src => src.Department))
);
Mot de la fin
Nous avons vu différents designs en .NET, en C♯ et en F♯. En partant d’un paradigme et des contraintes qu’il impose, on a pu arriver à des designs élégants, aboutissant à du code relativement simple à faire évoluer selon différents axes plausibles, dans l’esprit de l’OCP. On peut constater des styles de programmation différents, avec leurs propres avantages et inconvénients, et pourtant qui convergent dans l’émergence de concepts métier similaires. Cela nous amène à différentes conclusions, elles-mêmes matière à réflexion.
Le design est une affaire de choix et de compromis. Quel effort de développement cela représente ? Quelle dose de nouveauté pour améliorer le niveau technique sans être trop disruptif ? Le résultat sera-t-il maintenable en matière d’évolution, mais de aussi de correction de bugs ? Un design trop poussé pourrait permettre d’ajouter facilement des cas d’utilisation tout en s’avérant trop compliqué à déboguer car trop difficile ou coûteux à comprendre.
Sur cette problématique, on peut reprocher à l’OOP “pure” (en C♯) la grande quantité de classes créées pour résoudre le problème. Même si les classes sont petites, 10 à 20 lignes, et respectent le SRP, cela peut faire beaucoup de fichiers. Il faut savoir naviguer au sein d’un tel ensemble. Au contraire, les langages fonctionnels tels que F♯ permettent d’avoir un code succinct, dépourvu de trop de cérémonial tout en pouvant être déclaratif et explicite. Mais il faut oser sauter le pas et se lancer dans l’utilisation d’un langage fonctionnel, ce qui n’est pas encore très courant en France, C♯ restant le langage très majoritairement utilisé sur la plateforme .NET. C’est pourquoi avoir une approche orientée objet, appropriée en C♯, teintée de fonctionnel, peut représenter un bon compromis pour mettre en place un design souple mais pas trop verbeux.
Il s’agit aussi de culture et d’état d’esprit : connaître différents paradigmes de programmation, différents axes de design, savoir les combiner, ne pas se limiter aux choses connues ; savoir pousser son langage de programmation loin dans ses possibilités… Dans tous les cas, les choix de design ne doivent pas être ceux d’un individu, mais de l’ensemble des personnes de l’équipe de développement, sans pour autant niveler par le bas !
J’espère vous avoir apporté à la fois matière à réflexion et des éléments concrets pour agir : prendre soin de vos bases de code afin de les conserver en bon état le plus longtemps possible, au gré des évolutions métier (*). Enjoy coding! 👋
(*) Plutôt que de tout refaire tous les cinq ans parce que ce n’est plus maintenable, voire simplement pour passer aux derniers frameworks tendance !