Design d’un moteur de mapping en .NET – Modélisation du domaine en F♯
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
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’argumentoperation
:Operation
. Par contre, rien dans le corps de la fonctionproceed
code> ne permet d’inférer le type du paramètrelabel
, pas même l’expressionlabel.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 écrivantlabel: 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 :
- On part d’une déclinaison,
- On la convertit en une opération,
- On exécute cette opération sur le libellé de l’équipement,
- 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 :
operation
→Some operation
NoOp
→None
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 unEquipment
et uneVariation
en entrée et renvoyant unLabel
(*). - Le passage de
Variation
àLabel
s’effectue par la biais de plusieurs types d’Operation
:
→ D’abord on trouve la correspondance entreVariation
etOperation
.
→ Puis on procède à l’exécution de l’Operation
calculant leLabel
final à partir d’unLabel
initial.
(*) Attention, le contrat a changé : avant on renvoyait une
string
, maintenant c’est unLabel
. 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 :
- Extraction du libellé par déconstruction du
Label
dans la variablecontent: string
, - Comme précédemment, calcul du nouveau libellé,
- 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
etstring
, 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 fonctioncomputeLabel
de signatureEquipment -> Variation -> string
.Domain.Mapping
expose une fonctioncomputeLabel
de signatureLabel -> Schema -> Label
.Domain.Ports
expose les typesLabel
etSchema
, versions sécurisées et personnalisées deEquipment
etVariation
.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 typeOperation
et sa fonction de traitementproceed
.Shared
expose des fonctions utilitaires telles quedefaultIfNone
utile pour extraire la valeur contenue dans uneOption
.
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’unestring
mais c’est un concept à part entière du domaine, contrairement àstring
. Son usage principal se fait dans le moduleDomain.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 moduleApi.Adapters
et son utilisation dansDomain.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 leSchema
créés dansApi.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.