Avancé

Design d’un moteur de mapping en .NET – Modélisation du domaine en F♯

Temps de lecture : 13 minutes

Dans l’article précédent, nous avons vu les avantages apportés localement par F♯ mais nous n’avons pas vraiment fait de design. En orienté objet, on peut améliorer le design en s’inspirant des design patterns mais ces derniers ne sont pas pertinents en programmation fonctionnelle. C’est en matière de modélisation du domaine qu’il faut chercher. Voyons comment faire en F♯.

🏷 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♯

Expressivité du domaine métier

Un premier axe d’amélioration peut être cherché concernant l’expressivité du domaine métier. L’une des caractéristiques que l’on peut clarifier réside dans les trois opérations sur le libellé d’origine pour en produire un nouveau :

  • Exchange : échange du libellé initial pour un autre tout nouveau,
  • Replacement : remplacement d’une partie du libellé existant,
  • Supplement : ajout d’une précision à la fin.

F♯ permet de modéliser cela directement dans un type union, fournissant une véritable documentation vivante qui liste tous les cas et qui ne compile pas si l’on a oublié d’en traiter un. En ajoutant les paramètres associés permettant d’effectuer chaque opération ainsi que le cas NoOp (aucune opération) équivalent du None précédent, cela donne :

type Operation =
    | Exchange of other: string
    | Replacement of part: string * by: string
    | Supplement of suffix: string
    | NoOp

Le traitement d’une opération est simple :

let proceed (label: string) operation =
    match operation with
    | Exchange other -> other
    | Replacement(part, by) -> label.Replace(part, by)
    | Supplement suffix -> label + suffix
    | NoOp -> label

💡 Note : les cas indiqués dans le pattern matching sur operation (Exchange, Replacement…) permettent au compilateur d’inférer le type de l’argument operation : Operation. Par contre, rien dans le corps de la fonction proceedcode> ne permet d’inférer le type du paramètre label, pas même l’expression label.Replace(part, by)code> car celle-ci n’évoque pas une fonction mais une méthode. Or, contrairement aux fonctions dont la signature est connue du compilateur, la signature d’une méthode n’est connue que lorsque le compilateur connaît le type de l’objet source, ce que l’on cherche à inférer justement ici ! C’est pour cette raison que l’on précise le type de ce paramètre en écrivant label: string.On comprend mieux pourquoi il est préférable en F♯ d’écrire des fonctions externes aux types plutôt que des méthodes membres : l’inférence de type ne marche pas dans le second cas !

Côté sous-fonctions, elles n’opèrent plus sur le label, elles ne font que le mapping entre une Variation et une Operation, ce qui les épure :

let tryMapToExchange (variation: Variation) =
    match variation.Schema with
    | 7407  -> Exchange "Nombre de cylindres"
    | 15304 -> Exchange "Puissance (ch)"
    | 15305 -> Exchange "Régime de puissance maxi (tr/mn)"
    | _ -> NoOp

let tryMapToReplacementBySchema (variation: Variation) =
    match variation.Schema with
    | 7403 -> Replacement("litres / cm3", "litres")
    | 7402 -> Replacement("litres / cm3", "cm3")
    [...]
    | _ -> NoOp

let tryMapToReplacementByLocation (variation: Variation) =
    match variation.Schema, variation.Location with
    | 23301, 'F' -> Replacement("AV / AR", "AV")
    | 23301, 'R' -> Replacement("AV / AR", "AR")
    [...]
    | _ -> NoOp

let tryMapToSupplement (variation: Variation) =
    match variation.Schema with
    | 14103 -> Supplement " : largeur"
    | 14104 -> Supplement " : profil"
    | _ -> NoOp

Chaque fonction est dédiée à une opération donnée, tentant son mapping depuis la variation en entrée, d’où le nommage en “try” suivi de “MapTo” et du nom de l’opération. L’utilisation de NoOp à la place de None permet une signature des plus simples et explicites, Variation -> Operation.

Cela amène également à modifier l’opérateur <||> pour le spécialiser sur les opérations :

let (<||>) fa fb x = // ('a -> Operation) -> ('a -> Operation) -> ('a -> Operation)
    match fa x with
    | NoOp -> fb x
    | op -> op

Cela nous permet de créer une fonction chapeau mapToOperation: Variation -> Operation :

let mapToOperation =
    tryMapToExchange
    <||> tryMapToReplacementBySchema
    <||> tryMapToReplacementByLocation
    <||> tryMapToSupplement

Le traitement global devient alors des plus simples :

let computeLabel (equipment: Equipment) (variation: Variation) =
    variation
    |> mapToOperation
    |> proceed (equipment.Label.Trim())

C’est également un code très bien auto-documenté, dans un anglais compréhensible par un expert métier, ce qui facilite l’arrivée d’un nouveau membre dans l’équipe de développement. Pour l’exercice, on pourrait lire le code en français de la sorte :

  1. On part d’une déclinaison,
  2. On la convertit en une opération,
  3. On exécute cette opération sur le libellé de l’équipement,
  4. Libellé que l’on a d’abord épuré des éventuels espaces de début et de fin.

☝️ Note : cette stratégie de design est appelée Decoupling decisions from effects par Mark Seemann. Il la présente dans un contexte de pureté du domaine, lorsque l’exécution desdites opérations seraient impures (pour simplifier : dépendantes de l’extérieur) mais l’on peut également l’utiliser pour améliorer le découpage du problème métier et permettre au code de mieux exprimer la solution en termes métier.

💡 Code source sur le GitHub de SOAT ici.

Meilleure modélisation des opérations

Dans l’approche précédente, le cas NoOp est pratique mais fait figure d’intrus. Un expert métier qui lirait le type Operation comprendrait mieux ce code s’il était dénué du cas NoOp, sorte d’artifice de calcul.

Quand on enlève noOp, cela permet d’épurer proceed :

type Operation =
    | Exchange of other: string
    | Replacement of part: string * by: string
    | Supplement of suffix: string

let proceed (label: string) operation =
    match operation with
    | Exchange other -> other
    | Replacement (part, by) -> label.Replace(part, by)
    | Supplement suffix -> label + suffix

Par contre, cela affecte toutes les fonctions tryMapToOpx pour lesquelles il faut traiter le « cas de repli ». Il suffit de réutiliser le type Option avec la correspondance de cas suivante :

  • operationSome operation
  • NoOpNone

De même, on simplifie les noms de ces fonctions en enlevant le suffixe “try” :

let mapToExchange (variation: Variation) =
    match variation.Schema with
    | 7407  -> Some (Exchange "Nombre de cylindres")
    | 15304 -> Some (Exchange "Puissance (ch)")
    | 15305 -> Some (Exchange "Régime de puissance maxi (tr/mn)")
    | _ -> None

let mapToReplacementBySchema (variation: Variation) =
    match variation.Schema with
    | 7403 -> Some (Replacement("litres / cm3", "litres"))
    | 7402 -> Some (Replacement("litres / cm3", "cm3"))
    // [...]
    | _ -> None

let mapToReplacementByLocation (variation: Variation) =
    match variation.Schema, variation.Location with
    | 23301, 'F' -> Some (Replacement("AV / AR", "AV"))
    | 23301, 'R' -> Some (Replacement("AV / AR", "AR"))
    // [...]
    | _ -> None

let mapToSupplement (variation: Variation) =
    match variation.Schema with
    | 14103 -> Some (Supplement " : largeur")
    | 14104 -> Some (Supplement " : profil")
    | _ -> None

Cela nous permet de revenir à l’opérateur <||> générique et ne change rien à la manière de recomposer mapToOperation :

let (<||>) fa fb x =
    match fa x with
    | Some v -> Some v
    | None -> fb x

let mapToOperation =
    mapToExchange
    <||> mapToReplacementBySchema
    <||> mapToReplacementByLocation
    <||> mapToSupplement

Quant à computeLabel, pour utiliser la fonction proceed, il faut « l’élever dans le monde des Option ». Inversément, pour obtenir le libellé final, il faut sortir la valeur de l’option, ce que l’on fait avec defaultArg, en traitant le cas None en renvoyant le libellé original assaini :

let computeLabel (equipment: Equipment) (variation: Variation) =
    let label = equipment.Label.Trim()

    variation
    |> mapToOperation
    |> Option.map (proceed label)
    |> (fun x -> defaultArg x label)

💡 Code source sur le GitHub de SOAT ici.

Modélisation complète par les types

💡 On applique ici la façon de faire de Scott Wlaschin présentée dans son livre Domain Modeling Made Functional.

L’idée est de tout typer, données comme fonctions, avant même d’implémenter ces dernières. C’est une manière de modéliser le système en restant à un niveau conceptuel, dénué de détails d’implémentation, ce qui peut même se faire en collaboration avec un expert métier. Cela permet de visualiser une user story sous la forme d’un workflow dont les étapes prennent cette forme : type Step = Input(s) -> Output.

Pour être plus explicite, on va typer les Label, ce que l’on pourrait faire avec un simple alias type Label = string mais que l’on fait plutôt avec une single case union qui permet de renforcer le typage, et par la même occasion la détection anticipée de bugs à la compilation :

type Operation =
    | Exchange of other: string
    | Replacement of part: string * by: string
    | Supplement of suffix: string

type Label = Label of string

type ComputeLabel = Equipment -> Variation -> Label
type MapToOperation = Variation -> Operation option
type Proceed = Label -> Operation -> Label

Un tel modèle peut se lire ainsi :

  • Le workflow général est ComputeLabel prenant un Equipment et une Variation en entrée et renvoyant un Label (*).
  • Le passage de Variation à Label s’effectue par la biais de plusieurs types d’Operation :
    → D’abord on trouve la correspondance entre Variation et Operation.
    → Puis on procède à l’exécution de l’Operation calculant le Label final à partir d’un Label initial.

(*) Attention, le contrat a changé : avant on renvoyait une string, maintenant c’est un Label. Ce n’est pas toujours possible de changer le contrat : tout dépend des clients de notre système.

Le modèle impose des contraintes sur l’implémentation que l’on applique en modifiant la manière de déclarer nos fonctions : on n’agit plus directement sur le type des paramètres d’entrée, annoté ou inféré ; on annote directement le type de la fonction et on fournit une implémentation sous la forme d’une lambda :

let proceed : Proceed = fun label operation -> // [...]
let mapToExchange : MapToOperation = fun variation -> // [...]
let mapToReplacementBySchema : MapToOperation = fun variation -> // [...]
// [...]
let computeLabel : ComputeLabel = fun equipment variation -> // [...]

L’implémentation des fonctions mapToXxx n’est pas changée. Par contre, l’empaquetage du libellé dans un Label impacte celle de proceed où l’on procède désormais en trois étapes :

  1. Extraction du libellé par déconstruction du Label dans la variable content: string,
  2. Comme précédemment, calcul du nouveau libellé,
  3. Ré-empaquetage du résultat dans un Label.
let proceed : Proceed = fun label operation ->
    let (Label content) = label
    let newContent =
        match operation with
        | Exchange other -> other
        | Replacement (part, by) -> content.Replace(part, by)
        | Supplement suffix -> content + suffix
    Label newContent

De manière similaire, computeLabel devient :

let computeLabel : ComputeLabel = fun equipment variation ->
    let label = Label (equipment.Label.Trim())
    let result = variation |> mapToOperation |> Option.map (proceed label)
    match result with
    | Some x -> x
    | None -> label

Le code résultant est un peu plus verbeux mais cela reste raisonnable : l’ensemble fait moins de 80 lignes. Ce n’est pas si cher payé pour les bénéfices obtenus :

  • Clarté du modèle, lisible par un expert métier.
  • Guidage de l’implémentation par les types (aka Type Driven Development).

💡 Code source sur le GitHub de SOAT ici.

Protection du domaine

Actuellement, on utilise les champs Schema et Location du type Variation profondément dans notre système. Cela corrompt le domaine de deux façons :

  • Cela laisse entrer des concepts externes. Or, on veut pouvoir borner notre domaine dans son propre contexte, c’est-à-dire sa terminologie et ses concepts, et rien d’autre ! On parle de Bounded Contexts en jargon DDD.
  • Comme ces champs sont de type primitif, int et string, cela permet une combinatoire de valeurs non gérée par le domaine, pouvant amener à des états invalides. En se bornant aux états valides, on simplifie la logique et on se prémunit de bugs. On cherchera donc à appliquer la devise Make illegal states unrepresentable.

Pour conserver la « pureté » du domaine, il faut l’isoler, ce que l’on fait en découpant le système comme suit, en s’inspirant de l’architecture Ports and Adapters :

  • Api.Main est le module d’entrée exposant une fonction computeLabel de signature Equipment -> Variation -> string.
  • Domain.Mapping expose une fonction computeLabel de signature Label -> Schema -> Label.
  • Domain.Ports expose les types Label et Schema, versions sécurisées et personnalisées de Equipment et Variation.
  • Api.Adapters expose les fonctions de conversion entre les types externes et ceux du domaine : Equipment -> Label, Label -> string, Variation -> Schema. Ce module participe à la couche d’anti-corruption.
  • Domain.Operation est un module interne du domaine définissant le type Operation et sa fonction de traitement proceed.
  • Shared expose des fonctions utilitaires telles que defaultIfNone utile pour extraire la valeur contenue dans une Option.

La compilation F♯ est sensible à l’ordre : on ne peut accéder qu’à des éléments déclarés en amont. Les modules sont donc déclarés dans cet ordre :

Shared > Domain.Ports > Domain.Operation > Domain.Mapping > Api.Adapters > Api.Main

Regardons le contenu de ces modules, en incluant les open d’import des modules pour constater leur relation et surtout l’isolation du domaine :

Module Domain.Ports

type Label = Label of string

type InsuranceFeature = Years | Mileage
type Location = Front | Rear
type RechargeMeasure = Amperage | Voltage | Hours
type RechargeSpeed = Domestic | Fast
type SeatType = Driver | Passenger
type TyreTrait = Width | Profile
type VolumeUnit = Liters | Cm3

type Schema =
    | MaxPowerSpeed
    | NumberOfCylinders
    | PowerDin
    | Insurance of InsuranceFeature
    | Location of Location
    | Recharge of RechargeMeasure * RechargeSpeed
    | Seat of SeatType
    | Tyre of TyreTrait
    | Volume of VolumeUnit
    | Other
  • Le type Label consiste simplement en l’encapsulation d’une string mais c’est un concept à part entière du domaine, contrairement à string. Son usage principal se fait dans le module Domain.Operation.
  • Le type Schema est la traduction des numéros de schéma et des lettres de location de l’API externe en des concepts plus expressifs et bornés. Sa construction s’effectue dans le module Api.Adapters et son utilisation dans Domain.Mapping.
  • On remarque que les termes Location et Schema ont été repris dans le domaine mais ne signifient pas tout à fait la même chose.

Module interne Domain.Operation

Il expose le type Operation et sa fonction de traitement proceed

open Vehicle.Domain.Ports

type Operation =
    | Exchange of other: string
    | Replacement of part: string * by: string
    | Supplement of suffix: string

let proceed label operation =
    let (Label content) = label
    let newContent =
        match operation with
        | Exchange other -> other
        | Replacement(part, by) -> content.Replace(part, by)
        | Supplement suffix -> content + suffix
    Label newContent

☝️ Note : ce module est très court. On aurait pu l’intégrer directement au module Domain.Mapping mais on a plutôt opté pour une décomposition au plus fin des traitements. Sur des plus gros projets, on aura plutôt tendance à les rassembler dans un même fichier pour en limiter le nombre, source potentielle de lenteur de compilation et de casse-tête pour classer les fichiers dans le bon ordre de compilation.

Module Domain.Mapping

Le module comporte en premier lieu des fonctions privées traitant les données contenues dans certains schémas, afin de leur associer un libellé :

open Vehicle.Domain.Operation
open Vehicle.Domain.Ports
open Vehicle.Shared

let private mapInsurance = function // InsuranceFeature -> string
    | Years -> "durée (ans)"
    | Mileage -> "kilométrage"

let private mapLocation = function // Location -> string
    | Front -> "AV"
    | Rear -> "AR"

let private mapSeat = function // SeatType -> string
    | Driver -> "conducteur"
    | Passenger -> "passager"

let private mapRechargeMeasure = function // RechargeMeasure -> string
    | Amperage -> "ampérage (A)"
    | Voltage -> "voltage (V)"
    | Hours -> "durée (heures)"

let private mapRechargeSpeed = function // RechargeSpeed -> string
    | Domestic -> "recharge"
    | Fast -> "recharge rapide"

let private mapRecharge measure speed = // RechargeMeasure -> RechargeSpeed -> string
    sprintf "%s : %s" (mapRechargeSpeed speed) (mapRechargeMeasure measure)

let private mapTyre = function // TyreTrait -> string
    | Width -> "largeur"
    | Profile -> "profil"

let private mapVolume = function // VolumeUnit -> string
    | Liters -> "litres"
    | Cm3 -> "cm3"

💡 Note : hormis mapRecharge qui a deux paramètres d’entrée, ces fonctions ont un seul paramètre d’entrée qui est implicite : il vient de l’emploi du mot-clé function suivi d’un pattern matching permettant de définir le type de ce paramètre.

Ces helpers permettent de traiter tous les schémas dans un seul pattern matching afin de leur associer leur opération optionnelle :

let private tryMapToOperation = function // Schema -> Operation option
    | NumberOfCylinders -> Some(Exchange "Nombre de cylindres")
    | MaxPowerSpeed -> Some(Exchange "Régime de puissance maxi (tr/mn)")
    | PowerDin -> Some(Exchange "Puissance (ch)")
    | Insurance x -> Some(Replacement("an(s) / km", ": " + (mapInsurance x)))
    | Volume x -> Some(Replacement("litres / cm3", mapVolume x))
    | Seat x -> Some(Replacement("conducteur / passager", mapSeat x))
    | Recharge (x, y) -> Some (Replacement ("recharge (rapide) A / V / h", mapRecharge x y))
    | Location x -> Some(Replacement("AV / AR", mapLocation x))
    | Tyre x -> Some(Supplement(" : " + (mapTyre x)))
    | Other -> None

La fonction computeLabel consiste alors dans un workflow en trois étapes Schema → Operation option → Label option → Label, que l’on peut implémenter ainsi :

let computeLabel label schema =
    schema
    |> tryMapToOperation
    |> Option.map (proceed label)
    |> defaultIfNone label

Module Api.Adapters

Le module s’occupe du wrap / unwrap du libellé dans un Label :

open External.EquipmentApi
open Vehicle.Domain.Ports

let labelValue (Label value) = value

let parseEquipment (equipment: Equipment) = Label (equipment.Label.Trim())

Il gère aussi la création du Schema depuis la Variation, sous décomposée en deux traitements selon l’utilisation du seul champ Schema ou des deux champs Schema et Location :

let parseVariation (variation: Variation) =
    let parseBySchema next =
        match variation.Schema with
        | 7407 -> NumberOfCylinders
        | 15304 -> PowerDin
        | 15305 -> MaxPowerSpeed
        | 23502 | 24002 -> Insurance Years
        | 23503 | 24003 -> Insurance Mileage
        | 7403 -> Volume Liters
        | 7402 -> Volume Cm3
        | 14103 -> Tyre Width
        | 14104 -> Tyre Profile
        | _ -> next()

    let parseBySchemaAndLocation () =
        match variation.Schema, variation.Location with
        | 17811, 'D' | 17818, 'D' -> Seat Driver
        | 17811, 'P' | 17818, 'P' -> Seat Passenger
        | 53405, 'D' -> Recharge (Amperage, Domestic)
        | 53405, 'F' -> Recharge (Amperage, Fast)
        | 53404, 'D' -> Recharge (Voltage, Domestic)
        | 53404, 'F' -> Recharge (Voltage, Fast)
        | 53403, 'D' -> Recharge (Hours, Domestic)
        | 53403, 'F' -> Recharge (Hours, Fast)
        | 23301, 'F' -> Location Front
        | 23301, 'R' -> Location Rear
        | _ -> Other

    parseBySchemaAndLocation
    |> parseBySchema

L’assemblage des deux traitements repose sur un control flow de style continuation : si la fonction parseBySchema a épuisé les cas qu’elle pouvait traiter, elle cède la main à la fonction next passée en paramètre. Ce style peut conduire à un niveau d’imbrication d’appels pas facile à lire. Notre cas étant limité à deux fonctions, c’est plutôt élégant.

Module Api.Main

Il expose la fonction computeLabel qui ne fait qu’assembler les morceaux :

  • Le Label et le Schema créés dans Api.Adapters.
  • L’appel à la fonction du domaine computeLabel.
open External.EquipmentApi
open Vehicle.Api.Adapters
open Vehicle.Domain.Mapping

let computeLabel (equipment: Equipment) (variation: Variation) =
    let label  = parseEquipment equipment
    let schema = parseVariation variation

    computeLabel label schema
    |> labelValue

Conclusion sur la protection du domaine

L’architecture proposée nous a conduits à un découpage du problème encore plus fin. Le workflow est décomposé en plus d’étapes, chaque étape étant alors simplifiée. Cela nécessite plus de réflexion pour bien réaliser le découpage et trouver les bonnes abstractions, plus de code à écrire et à assembler. Cela a transformé une complexité locale (cf. première version de computeLabel) en complexité globale, répartie sur une base de code plus large mais localement plus simple.

❝ Un grand principe d’une architecture logicielle est de réussir à développer un ensemble de composants simples, qui assemblés, permettent de résoudre un problème complexe. C’est le principe d’émergence appliqué à l’architecture logicielle. ❞ • ✍️ Arnaud Lemaire • 🔗 Source

Une telle base de code est-elle plus maintenable ? La réponse ne peut être dénuée de subjectivité. De mon point de vue, je pense que oui, on gagne en maintenabilité. Chaque fonction étant plus simple, elle a moins de chance de contenir un bug. Une erreur est possible au niveau de l’assemblage mais est vite repérée et corrigée. De plus, cela a permis de révéler des concepts métier cachés, comme ici les « schémas » propres au domaine. Plus le code est explicite en étant l’expression claire du métier, plus il est compréhensible et donc maintenable.

On a été plus loin dans la modélisation du domaine, dans une approche inspirée du DDD. Cela permet de répondre à une dernière question : dans quelles circonstances une telle architecture est-elle pertinente ? Bingo ! Les mêmes que le DDD, à savoir un métier relativement complexe mais surtout qui est stratégique pour l’entreprise, qui lui permet de se positionner et de se différencier de la concurrence, tout ce qui est du domaine de sa « sauce maison », ceci dans une perspective à moyen et long termes.

💡 Code source sur le GitHub de SOAT ici.

Conclusion

Comme je débute en F♯, je suis sûr qu’il existe d’autres manières de faire, plus élégantes ou idiomatiques. Mais l’objectif n’était pas de produire le meilleur F♯. Il s’agissait de comparer de l’Impératif en C♯ à du Fonctionnel en F♯.

On voit que la logique générale est la même : la décomposition en sous fonctions selon le type d’opération. Par contre, une fois familiarisé avec la syntaxe de F♯, les principes de la programmation fonctionnelle comme la « composabilité », et les façons de les implémenter en F♯, il est difficile de ne pas constater que le code en F♯ est à la fois plus succinct et plus expressif.

Le F♯ nous a également permis une modélisation du domaine sous la forme de types, avec une granularité modulable selon la qualité souhaitée, pour s’aligner avec le degré d’importance du métier en matière de stratégie d’entreprise.

En C♯, l’approche impérative peut être avantageuse dans une optique de gain de performance. Concernant l’élégance du code, de mon point de vue, c’est en suivant une approche orientée objet que l’on met le mieux à profit C♯. Cela sera l’objet de l’article suivant.

Nombre de vue : 37

AJOUTER UN COMMENTAIRE