Utiliser les records C# 9 comme ids fortement typés
Ids fortement typés
Les entités ont généralement des ids de type entier, GUID ou chaîne de caractères, parce que ces types sont supportés nativement par les bases de données. C'est un fait largement accepté et rarement remis en question. Pourtant, si toutes nos entités ont des ids de même type, il est très facile de les mélanger accidentellement, et d'utiliser par exemple l'id d'un produit là où l'id d'une commande était attendu. C'est d'ailleurs une source de bugs assez fréquente.
public void AddProductToOrder(int orderId, int productId, int count) { ... } ... // Oups, on a inversé les arguments ! AddProductToOrder(productId, orderId, int count);
Le code ci-dessus compile sans problème, mais ne fera sans doute pas ce qu'il faut à l'exécution…
Heureusement, il existe une solution à ce problème : les ids fortement typés. L'idée est simple : déclarer un type spécifique pour les ids de chaque type d'entité. En reprenant l'exemple précédent, on aurait des types OrderId
et ProductId
au lieu de int
:
// Ids fortement typés à la place de int public void AddProductToOrder(OrderId orderId, ProductId productId, int count) { ... } ... // Oups, on a inversé les arguments ! AddProductToOrder(productId, orderId, int count);
Dans ce code, on a commis la même erreur que précédemment (les arguments orderId
et productId
sont inversés lors de l'appel), mais dans ce cas, les types sont différents, donc le compilateur détecte le problème et signale une erreur. On peut donc la corriger avant que ça n'explose en production !
Implémentation d'un id fortement typé
Andrew Lock a écrit une série très complète à ce sujet sur son blog, que je vous encourage vivement à lire. Dans les grandes lignes, l'implémentation qu'il propose ressemble à quelque chose comme ça :
public readonly struct ProductId : IEquatable<ProductId> { public ProductId(int value) { Value = value; } public int Value { get; } public bool Equals(ProductId other) => other.Value == Value; public override bool Equals(object obj) => obj is ProductId other && Equals(other); public override int GetHashCode() => Value.GetHashCode(); public override string ToString() => $"ProductId {Value}"; public static bool operator ==(ProductId a, ProductId b) => a.Equals(b); public static bool operator !=(ProductId a, ProductId b) => !a.Equals(b); }
Rien de très compliqué dans ce code, mais devoir écrire ça pour chaque entité de notre modèle est un peu fastidieux… Dans sa série d'articles, Andrew présente un package NuGet qu'il a écrit pour générer automatiquement ce code (et quelques petites choses en plus). Une autre option serait d'utiliser les générateurs de source de C# 9 pour obtenir le même résultat. Mais en fait, C# 9 apporte aussi une autre fonctionnalité qui serait peut-être encore plus adaptée pour les ids fortement typés…
Les types record
Les types record sont des types immuables par défaut avec une sémantique de valeur. Ils implémentent automatiquement tout ce qu'on a écrit manuellement dans notre structure ProductId
(Equals
, GetHashCode
, etc.), et proposent une syntaxe très concise appelée positional records. Si on réécrit notre type ProductId
précédent sous forme de record, ça donne ça :
public record ProductId(int Value);
Oui, vous lisez bien, c'est juste une ligne (voire une demi-ligne !). Et ça fait tout ce que faisait notre version écrite manuellement, et pas mal d'autres petites choses en bonus.
La principale différence est la suivante : notre ProductId
écrit manuellement était une struct
, donc un type valeur, mais les records sont des types référence, qui peuvent donc être null. Ce n'est pas forcément un problème, surtout si on utilise les nullable reference types, mais c'est quelque chose à garder à l'esprit.
Avec cette feature, déclarer un id fortement typé pour chaque entité du modèle ne semble plus du tout insurmontable ; cela permet d'avoir les avantages du typage fort quasiment sans effort. Il reste bien sûr quelques problématiques annexes à traiter pour utiliser ces ids fortement typés (notamment la sérialisation JSON et l'utilisation avec Entity Framework Core), mais ce sera pour un autre article !
Commentaires