Accueil Nos publications Blog Design d’un moteur de Mapping en .NET – Avancée en orienté objet en C♯

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
  1. Approche impérative en C♯
  2. Approche fonctionnelle en F♯
  3. Modélisation du domaine en F♯
  4. Approche orientée objet en C♯
  5. Avancée en orienté objet en C♯

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éthode string 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 de Rules est néanmoins très proche de celle de Rule, gardant la même sémantique. C’est là le problème : parler de Rules à appliquer sur une Variation et une string 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 interface IOperation { string ApplyOn(string label); } implémentée par Rule, 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 et ApplyOn) sont indépendantes. Si l’on appelait IsSatisfiedBy depuis ApplyOn et si l’on émettait une exception dans le cas où IsSatisfiedBy renvoie false, on aurait un objet IOperation 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 objet Rules va donc suivre l’interface IOperation SelectOperationAdaptedTo(Variation variation);. Ainsi, même lorsqu’aucune règle n’est satisfaite, on va renvoyer une opération par défaut plutôt que null, ce qui est désigné par le pattern Null object. Cette règle est toujours satisfaite et renvoie le label 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 :

RuleChainBuilder Diagram

Détails :

  • Case() crée un builder de type TakingCriteriaOrEnding exposé sous l’interface simplifiée ITakingCriteria afin de bloquer Case().Else() et forcer à avoir au moins une règle définie à partir du When(criteria).
  • When(criteria) sert à stocker le critère de la règle à construire et renvoie un nouveau builder de type TakingOperation. 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 ou Replace) et renvoyant une nouvelle instance de TakingCriteriaOrEnding afin d’ajouter d’autres règles ou de clore la procédure de build.
  • TakingCriteriaOrEnding expose deux méthodes :
    • Comme ITakingCriteria, on a la méthode When(criteria) pour ajouter une règle.
    • On a également la méthode Else(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 builder TakingCriteriaOrEnding 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 type ImmutableStack pour que notre builder soit immuable.
  • L’interface ITakingCriteria et les deux classes TakingCriteriaOrEnding et TakingOperation doivent avoir le même niveau de visibilité que RuleChainBuilder, ici public. Cependant, elles sont imbriquées dans RuleChainBuilder 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é par EndRuleChainWith(), 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érique TData plutôt que deux (TDataIn et TDataOut), 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 avec ComputeLabel(), dont le cœur consiste en une opération Label -> Label.
  • Le builder générique supporte toute opération de type TData -> TData, en proposant une seule et unique méthode Then(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ée Given<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 classe VariationCondition.
  • Les Opérations portent une Donnée qui est le Label, de type string. Append, ExchangeWith et Replace redeviennent des méthodes Factory, placées dans une classe LabelOperation.

Generic rule chain builder diagram

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 et TData, 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);

Generic rule chain builder diagram

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 !