Utiliser les types de données algébriques en .NET Framework / .NET — concevoir les états et les résultats avec les types
· Mis à jour le: · Go Komura · .NET, .NET Framework, C#, F#, Types de données algébriques, Union discriminée, Modélisation du domaine, Réutilisation des actifs existants
1. Ce qu’il faut comprendre en premier
Quand on écrit des applications métier en .NET, on rencontre souvent des valeurs de retour ou des états comme celui-ci.
public class CreateUserResult
{
public bool IsSuccess { get; set; }
public User User { get; set; }
public string ErrorCode { get; set; }
public string ErrorMessage { get; set; }
}
À première vue, cela paraît simple à comprendre, mais ce type laisse la porte ouverte à de nombreux « états qui ne devraient pas exister ».
Par exemple, on peut construire des valeurs comme celles-ci :
IsSuccess == truealors queUser == nullIsSuccess == truealors queErrorCodeest renseignéIsSuccess == falsealors queUserest renseignéErrorCode == "DuplicateEmail"alors queErrorMessage == null- Un nouveau code d’erreur a été ajouté, mais le traitement côté appelant n’a pas été mis à jour
Un type de ce genre peut sembler pratique au départ, mais plus le projet grossit, plus il pèse sur les lecteurs et les personnes chargées de la maintenance.
C’est là qu’intervient l’idée des types de données algébriques.
Le nom « type de données algébrique » sonne un peu formel, mais en pratique, il est plus simple de le voir ainsi :
Exprimer, avec un type — et non avec un commentaire ou une convention de nommage — le fait que « cette valeur a un ensemble de formes possibles déterminé à l’avance ».
Par exemple, on peut exprimer que le résultat de la création d’un utilisateur est exactement l’un des cas suivants :
CreateUserResult =
Created(User)
ou DuplicateEmail(email)
ou WeakPassword(reason)
ou SystemFailure(message)
En cas de succès, il y a un User.
En cas de doublon d’e-mail, il y a un email.
En cas de mot de passe faible, il y a un reason.
En cas d’erreur système, il y a un message.
Chaque cas ne porte que les données dont il a besoin.
Le succès et l’échec ne peuvent jamais être vrais en même temps.
Un état « succès mais sans User » ne peut tout simplement pas être construit.
En .NET, on peut mettre cette idée en œuvre avec les unions discriminées de F#, ou en C# avec des hiérarchies de classes sealed, des hiérarchies de record, des bibliothèques comme OneOf, voire les futurs types union de C#.
Cet article présente comment utiliser les types de données algébriques aussi bien sur .NET Framework que sur le .NET actuel, ainsi que leurs bénéfices et leurs points de vigilance en pratique.
Par ailleurs, tout le code présenté dans cet article est publié sur GitHub sous forme d’un ensemble d’exemples compilables et exécutables (une bibliothèque, des démonstrations illustrant chaque schéma d’implémentation, ainsi que des tests unitaires vérifiant l’exhaustivité de Match, les transitions d’état et la conversion en DTO).
dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)
2. Qu’est-ce qu’un type de données algébrique ?
Les types de données algébriques sont souvent désignés par leur abréviation anglaise, ADT (Algebraic Data Type).
Un ADT est, en gros, la combinaison de deux sortes de types :
- Le type produit : un type qui porte à la fois A et B
- Le type somme : un type qui est soit A, soit B
Les classes, les structs et les records de .NET sont, la plupart du temps, utilisés comme des « types produit ».
public sealed class Address
{
public string PostalCode { get; }
public string Prefecture { get; }
public string City { get; }
public string Street { get; }
public Address(string postalCode, string prefecture, string city, string street)
{
PostalCode = postalCode;
Prefecture = prefecture;
City = city;
Street = street;
}
}
Sur le plan de la signification, cela donne :
Address = PostalCode et Prefecture et City et Street
Le type somme, à l’inverse, exprime « l’un ou l’autre ».
PaymentResult =
Succeeded(receiptNo)
ou InsufficientFunds(shortage)
ou Rejected(reason)
ou NetworkFailure(message)
Ici, la signification est :
PaymentResult = Succeeded ou InsufficientFunds ou Rejected ou NetworkFailure
Exprimer ce « ou » sous forme de type est justement la partie des types de données algébriques la plus utilisée en pratique.
En F#, on peut l’écrire naturellement comme une fonctionnalité du langage.
type PaymentResult =
| Succeeded of receiptNo: string
| InsufficientFunds of shortage: decimal
| Rejected of reason: string
| NetworkFailure of message: string
Pendant longtemps, C# n’a disposé d’aucune fonctionnalité standard équivalente aux unions discriminées de F#. C’est pourquoi, en C#, on les a exprimées avec des hiérarchies de classes et des bibliothèques.
Cela dit, l’idée elle-même fonctionne très bien en C# aussi.
Ce qui compte, ce n’est pas d’utiliser telle ou telle syntaxe, mais ce seul point :
« Rendre impossible, dès le départ, la construction d’un état invalide. »
3. Pourquoi bool et enum seuls ne suffisent pas
Pour de petits traitements, bool ou enum peuvent sembler suffisants.
Par exemple, une valeur de retour comme celle-ci :
public enum PaymentStatus
{
Succeeded,
InsufficientFunds,
Rejected,
NetworkFailure
}
public sealed class PaymentResponse
{
public PaymentStatus Status { get; set; }
public string ReceiptNo { get; set; }
public decimal? Shortage { get; set; }
public string Reason { get; set; }
public string Message { get; set; }
}
Mais dans cette forme, la relation entre Status et chaque propriété n’est pas exprimée par le système de types.
ReceiptNo n’est nécessaire que lorsque Status == Succeeded.
Shortage n’est nécessaire que lorsque Status == InsufficientFunds.
Reason n’est nécessaire que lorsque Status == Rejected.
Message n’est nécessaire que lorsque Status == NetworkFailure.
Cette règle vit en dehors du code.
Elle repose sur des commentaires, des documents de spécification, des tests, des accords implicites et la mémoire de la personne qui a implémenté le code.
Il en résulte une prolifération de code défensif de ce type :
if (response.Status == PaymentStatus.Succeeded)
{
if (string.IsNullOrEmpty(response.ReceiptNo))
{
throw new InvalidOperationException("ReceiptNo is required.");
}
return response.ReceiptNo;
}
Ce genre de code défensif a sa place dans certaines situations, mais on aurait souvent pu l’éviter grâce à une meilleure conception des types.
Si on l’exprime comme un type de données algébrique, chaque cas ne porte que les données dont il a besoin.
Succeeded porte receiptNo
InsufficientFunds porte shortage
Rejected porte reason
NetworkFailure porte message
Avec cette conception, on ne peut pas construire une valeur Succeeded sans receiptNo.
Autrement dit, au lieu de vérifier l’état après coup, on rend impossible dès le départ la construction d’un état invalide.
4. Une implémentation utilisable même sur .NET Framework : une hiérarchie de classes sealed
Pour les systèmes existants, y compris .NET Framework, le schéma le plus facile à adopter est une classe abstraite de base + des classes sealed imbriquées + une méthode Match.
Il reste utilisable même avec d’anciennes versions de C# et ne nécessite aucune fonctionnalité particulière du runtime.
À titre d’exemple, modélisons le résultat de la création d’un utilisateur.
public abstract class CreateUserResult
{
private CreateUserResult()
{
}
public sealed class Created : CreateUserResult
{
internal Created(User user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
User = user;
}
public User User { get; }
}
public sealed class DuplicateEmail : CreateUserResult
{
internal DuplicateEmail(string email)
{
if (email == null) throw new ArgumentNullException(nameof(email));
Email = email;
}
public string Email { get; }
}
public sealed class WeakPassword : CreateUserResult
{
internal WeakPassword(string reason)
{
if (reason == null) throw new ArgumentNullException(nameof(reason));
Reason = reason;
}
public string Reason { get; }
}
public sealed class SystemFailure : CreateUserResult
{
internal SystemFailure(string message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
Message = message;
}
public string Message { get; }
}
public static CreateUserResult Ok(User user)
=> new Created(user);
public static CreateUserResult EmailAlreadyUsed(string email)
=> new DuplicateEmail(email);
public static CreateUserResult PasswordIsWeak(string reason)
=> new WeakPassword(reason);
public static CreateUserResult Failed(string message)
=> new SystemFailure(message);
public T Match<T>(
Func<Created, T> created,
Func<DuplicateEmail, T> duplicateEmail,
Func<WeakPassword, T> weakPassword,
Func<SystemFailure, T> systemFailure)
{
if (created == null) throw new ArgumentNullException(nameof(created));
if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));
var c = this as Created;
if (c != null) return created(c);
var d = this as DuplicateEmail;
if (d != null) return duplicateEmail(d);
var w = this as WeakPassword;
if (w != null) return weakPassword(w);
var f = this as SystemFailure;
if (f != null) return systemFailure(f);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Côté appelant, on peut écrire ceci :
CreateUserResult result = service.CreateUser(command);
string message = result.Match(
created => "Utilisateur créé : " + created.User.Id,
duplicate => "Cette adresse e-mail est déjà utilisée : " + duplicate.Email,
weak => "Le mot de passe est trop faible : " + weak.Reason,
failure => "Échec de la création de l'utilisateur : " + failure.Message);
L’avantage de cette forme est qu’elle fonctionne aussi bien sur .NET Framework que sur le .NET actuel.
Created, DuplicateEmail, WeakPassword et SystemFailure sont tous des CreateUserResult, mais chacun porte des données différentes.
Seul Created porte un User.
Seul DuplicateEmail porte un Email.
Seul WeakPassword porte un Reason.
Seul SystemFailure porte un Message.
On ne peut pas construire une valeur qui exprime à la fois le succès et l’échec.
De plus, si l’on impose aux appelants de passer par Match, on peut les forcer à traiter tous les cas.
Supposons que l’on ajoute un nouveau cas, TemporaryBlocked :
public sealed class TemporaryBlocked : CreateUserResult
{
internal TemporaryBlocked(DateTimeOffset until)
{
Until = until;
}
public DateTimeOffset Until { get; }
}
On ajoute alors aussi un paramètre Func<TemporaryBlocked, T> à la méthode Match.
Tous les appels existants à result.Match(...) deviennent alors des erreurs de compilation. C’est une bonne erreur : elle permet de découvrir, dès la compilation, qu’« un nouveau cas a été ajouté mais qu’un appelant ne le gère pas encore ».
5. Fermer l’ensemble des cas avec un constructeur private
En C#, pour exprimer un type somme, il est important de fermer autant que possible l’ensemble des cas.
Si le constructeur de la classe de base est protected, il reste possible d’en hériter de l’extérieur.
public abstract class PaymentResult
{
protected PaymentResult()
{
}
}
Sous cette forme, un autre assembly ou une autre partie du code peut créer un type comme celui-ci :
public sealed class UnknownPaymentResult : PaymentResult
{
}
L’ensemble des cas de PaymentResult n’est alors plus fermé.
On voulait dire « ce type est l’un de Succeeded / InsufficientFunds / Rejected / NetworkFailure », mais d’autres cas peuvent s’y ajouter.
Une contre-mesure réaliste, utilisable même sur .NET Framework, consiste à rendre private le constructeur de la classe de base et à définir les types de cas comme des types imbriqués de la classe de base.
public abstract class PaymentResult
{
private PaymentResult()
{
}
public sealed class Succeeded : PaymentResult
{
internal Succeeded(string receiptNo)
{
ReceiptNo = receiptNo;
}
public string ReceiptNo { get; }
}
public sealed class InsufficientFunds : PaymentResult
{
internal InsufficientFunds(decimal shortage)
{
Shortage = shortage;
}
public decimal Shortage { get; }
}
public static PaymentResult Success(string receiptNo)
=> new Succeeded(receiptNo);
public static PaymentResult Insufficient(decimal shortage)
=> new InsufficientFunds(shortage);
}
Un type imbriqué peut accéder aux membres private du type qui l’englobe.
C’est pourquoi seuls les types de cas imbriqués peuvent hériter de PaymentResult.
Avec ce schéma, on peut obtenir en C# quelque chose de proche d’un « ensemble de cas fermé ».
Cela dit, le compilateur C# ne fournit pas une vérification d’exhaustivité aussi complète que celle de F#.
C’est pourquoi, lorsqu’on utilise ce schéma en C#, il est recommandé d’éviter de disperser des switch un peu partout et de concentrer plutôt le traitement dans une méthode Match.
6. Sur le .NET actuel, une hiérarchie de record permet d’écrire plus simplement
Si l’on peut se baser sur .NET 5 ou une version ultérieure, le record de C# permet d’écrire de manière bien plus concise des types de cas centrés sur les données.
public abstract record CreateUserResult
{
private CreateUserResult()
{
}
public sealed record Created(User User) : CreateUserResult;
public sealed record DuplicateEmail(string Email) : CreateUserResult;
public sealed record WeakPassword(string Reason) : CreateUserResult;
public sealed record SystemFailure(string Message) : CreateUserResult;
}
Côté appelant, on peut utiliser le pattern matching et les expressions switch.
static string ToMessage(CreateUserResult result)
{
return result switch
{
CreateUserResult.Created { User: var user }
=> $"Utilisateur créé : {user.Id}",
CreateUserResult.DuplicateEmail { Email: var email }
=> $"Cette adresse e-mail est déjà utilisée : {email}",
CreateUserResult.WeakPassword { Reason: var reason }
=> $"Le mot de passe est trop faible : {reason}",
CreateUserResult.SystemFailure { Message: var message }
=> $"Échec de la création de l'utilisateur : {message}",
_ => throw new InvalidOperationException("Résultat inconnu.")
};
}
Cette façon d’écrire est très idiomatique en C# et se lit facilement.
Il y a cependant des points de vigilance.
Les hiérarchies de record sont pratiques pour réduire le code répétitif de comparaison de valeurs et d’affichage. Mais il est plus prudent de ne pas les considérer comme fermant l’ensemble des cas avec la même force que le schéma « classe ordinaire + constructeur private + cas sealed imbriqués » présenté au chapitre précédent.
En particulier, pour les record classes non sealed, des membres générés propres aux records, comme le constructeur de copie, entrent en jeu. Si l’on veut absolument empêcher toute dérivation externe, ou fermer strictement l’ensemble des cas, il est plus solide de choisir la hiérarchie de classes du chapitre précédent, les unions discriminées de F#, ou une bibliothèque union / Source Generator éprouvée.
Par ailleurs, ajouter _ dans cette expression switch donne l’impression que l’on peut accepter des types dérivés inconnus.
Mais si la conception traite l’ensemble des cas comme fermé, ce _ est en réalité une branche censée n’être « jamais atteinte ».
En C#, dans les versions stables actuelles, on ne peut pas espérer une vérification d’exhaustivité aussi stricte que celle des unions discriminées de F#. C’est pourquoi, même lorsqu’on utilise une hiérarchie de record en C#, il est plus sûr de s’appuyer sur l’une de ces deux approches :
- Fournir une méthode
Matchpour forcer l’appelant à traiter tous les cas - Localiser l’usage de
switchau lieu de le disperser un peu partout
On peut, par exemple, ajouter Match aussi à la hiérarchie de record.
public abstract record CreateUserResult
{
private CreateUserResult()
{
}
public sealed record Created(User User) : CreateUserResult;
public sealed record DuplicateEmail(string Email) : CreateUserResult;
public sealed record WeakPassword(string Reason) : CreateUserResult;
public sealed record SystemFailure(string Message) : CreateUserResult;
public T Match<T>(
Func<Created, T> created,
Func<DuplicateEmail, T> duplicateEmail,
Func<WeakPassword, T> weakPassword,
Func<SystemFailure, T> systemFailure)
{
return this switch
{
Created x => created(x),
DuplicateEmail x => duplicateEmail(x),
WeakPassword x => weakPassword(x),
SystemFailure x => systemFailure(x),
_ => throw new InvalidOperationException("Résultat inconnu.")
};
}
}
En procédant ainsi, l’appelant traite toujours les cas en ayant conscience de l’ensemble.
var message = result.Match(
created => $"Créé : {created.User.Id}",
duplicate => $"Doublon : {duplicate.Email}",
weak => $"Le mot de passe est faible : {weak.Reason}",
failure => $"Échec : {failure.Message}");
L’avantage des records est de réduire le code répétitif lié à la comparaison de valeurs, à l’affichage et à la copie. Cela dit, dans une bibliothèque partagée qui cible aussi .NET Framework, il est parfois plus simple d’utiliser une classe ordinaire plutôt que de forcer l’usage des records ou des propriétés init-only.
Il vaut mieux privilégier « enfermer dans les types l’état que l’on veut exprimer » plutôt que « utiliser une nouvelle syntaxe ».
7. Utiliser les unions discriminées de F#
Le langage qui traite le plus naturellement les types de données algébriques sur .NET est F#.
F# fournit les unions discriminées comme fonctionnalité du langage.
type CreateUserResult =
| Created of user: User
| DuplicateEmail of email: string
| WeakPassword of reason: string
| SystemFailure of message: string
Côté appelant aussi, c’est naturel.
let toMessage result =
match result with
| Created user -> $"Utilisateur créé : {user.Id}"
| DuplicateEmail email -> $"Cette adresse e-mail est déjà utilisée : {email}"
| WeakPassword reason -> $"Le mot de passe est trop faible : {reason}"
| SystemFailure message -> $"Échec de la création de l'utilisateur : {message}"
Ce qui est agréable avec F#, c’est que l’énumération des cas et le pattern matching sont intégrés au langage.
Lorsqu’on ajoute un cas, il devient facile de repérer les traitements manquants côté match.
De même, un type comme Option<'T>, qui exprime la présence ou l’absence d’une valeur, s’utilise naturellement comme une union discriminée.
let tryFindUser id : User option =
// Some user si trouvé, None sinon
failwith "sample"
En renvoyant un option plutôt qu’un null, la possibilité d’« absence » apparaît dans le type.
Les unions discriminées de F# sont compilées comme des types .NET ordinaires, on peut donc les utiliser aussi bien dans des projets F# ciblant .NET Framework que dans des projets F# ciblant le .NET actuel.
Cependant, lorsqu’on manipule directement une union discriminée F# depuis C#, l’expérience n’est parfois pas aussi naturelle qu’à l’intérieur de F#.
C’est pourquoi la répartition suivante est réaliste en pratique :
- À l’intérieur de la logique de domaine F#, utiliser activement les unions discriminées F#
- Pour les API publiques fréquemment appelées depuis C#, convertir vers des DTO ou des hiérarchies de classes confortables à manipuler en C#
- Aux frontières, effectuer une correspondance vers la représentation adaptée aux besoins du JSON ou de la base de données
Si l’on parvient à séparer ainsi « des types forts à l’intérieur du domaine, des types faciles à manipuler à la frontière externe », le mélange de F# et de C# devient beaucoup plus gérable.
8. Utiliser une bibliothèque comme OneOf
Si l’on souhaite exprimer facilement un type somme en C#, une bibliothèque comme OneOf constitue aussi une option.
Par exemple, on peut exprimer ainsi une valeur de retour :
using OneOf;
public sealed class DuplicateEmail
{
public DuplicateEmail(string email)
{
Email = email;
}
public string Email { get; }
}
public sealed class WeakPassword
{
public WeakPassword(string reason)
{
Reason = reason;
}
public string Reason { get; }
}
public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
if (EmailExists(command.Email))
{
return new DuplicateEmail(command.Email);
}
if (!IsStrongPassword(command.Password))
{
return new WeakPassword("Utilisez au moins 12 caractères.");
}
return CreateUserCore(command);
}
Côté appelant, on traite le résultat avec Match.
var result = service.CreateUser(command);
var message = result.Match(
user => $"Créé : {user.Id}",
duplicate => $"Doublon : {duplicate.Email}",
weak => $"Le mot de passe est faible : {weak.Reason}");
OneOf<User, DuplicateEmail, WeakPassword> signifie « cette valeur est exactement l’un des trois types User, DuplicateEmail ou WeakPassword ».
L’avantage de cette approche est d’obtenir une valeur de retour locale pratique sans avoir à créer de classe de base dédiée.
Elle convient particulièrement bien pour exprimer des valeurs de retour de ce genre dans la couche des services applicatifs ou des cas d’usage :
Résultat de création d'utilisateur = User ou DuplicateEmail ou WeakPassword
Résultat de récupération de produit = Product ou NotFound ou AccessDenied
Résultat de paiement = Receipt ou InsufficientFunds ou PaymentRejected
Il y a cependant des points de vigilance.
Si l’on expose directement dans une API publique un type comme OneOf<A, B, C>, la signification métier peut s’estomper.
Par exemple, les deux signatures suivantes présentent une structure identique si l’on ne regarde que les paramètres de type :
OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)
Pratique dans un périmètre restreint, mais si l’on veut expliciter le sens métier, un type dédié se lit mieux.
public abstract class GetUserResult
{
// Found / NotFound / AccessDenied
}
Voici quelques repères pour choisir :
- Pour une valeur de retour locale,
OneOfest pratique - Pour un concept qui revient constamment dans le domaine, créer un type dédié
- Si la stabilité de l’API publique compte, utiliser un type de résultat nommé
Notez que OneOf prend en charge une large gamme de cibles, y compris .NET Framework et .NET Standard, ce qui en fait une option facile à introduire dans des actifs .NET Framework existants.
9. Utiliser une bibliothèque basée sur un Source Generator
Sur le .NET actuel, il existe aussi des bibliothèques qui génèrent des types de style union discriminée à l’aide de Source Generators.
Certaines, par exemple, génèrent du code pour Switch, Map, la validation ou l’intégration à la sérialisation, simplement en posant un attribut.
Voici à quoi cela ressemble, dans les grandes lignes :
[Union]
public partial record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}
Ce type de bibliothèque réduit le code répétitif écrit à la main pour Match ou Switch.
Certaines se combinent aussi avec un Analyzer pour signaler les cas non traités.
Cependant, si l’on utilise ces bibliothèques dans un système existant incluant .NET Framework, il faut vérifier les points suivants :
- Les TFM ciblés prennent-ils en charge .NET Framework ?
- L’environnement SDK / Visual Studio / MSBuild nécessaire aux Source Generators est-il en place ?
- L’environnement de CI produit-il le même résultat de génération ?
- Peut-on déboguer le code généré ?
- L’intégration JSON / base de données / OpenAPI aux frontières de l’application se comporte-t-elle comme prévu ?
En particulier, dans les anciens projets .NET Framework, les packages qui supposent l’usage de Source Generators peuvent ne pas fonctionner tels quels.
Si l’on souhaite un fort support de .NET Framework, il est plus sûr de commencer par une hiérarchie de classes écrite à la main ou par OneOf.
10. À propos du type union de C# 15
En juin 2026, le type union de C# 15 est apparu comme fonctionnalité en aperçu (preview).
Dans l’orientation prise par cet aperçu, on peut déclarer que « ce type est exactement l’un des types spécifiés ».
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
Côté appelant, chaque cas est traité par pattern matching.
static string Describe(Pet pet)
{
return pet switch
{
Cat cat => $"Cat: {cat.Name}",
Dog dog => $"Dog: {dog.Name}",
Bird bird => $"Bird: {bird.Name}",
Pet { Value: null } => "Unknown pet"
};
}
Si cette fonctionnalité se stabilise, C# pourra lui aussi traiter plus naturellement un « ensemble de types fermé » et un « pattern matching exhaustif ».
Si le type généré au stade de l’aperçu est un struct, une valeur dont le champ interne Value est null — comme default(Pet) — peut aussi circuler.
Une méthode publique qui reçoit une valeur union doit donc traiter ce genre de valeur par défaut de manière défensive.
Cela dit, une fonctionnalité en aperçu doit être évaluée avec prudence avant d’entrer dans du code de production réel.
La spécification du langage, le support des IDE, les types d’assistance du runtime, les Analyzers et l’intégration avec les sérialiseurs peuvent tous encore évoluer avant la sortie officielle.
Pour le travail concret d’aujourd’hui, ce positionnement est donc réaliste :
- Pour une validation ou une recherche technique sur du nouveau code, essayer le union de C# vaut le coup
- Pour du code de production maintenu sur le long terme, utiliser les options éprouvées : les DU de F#, les hiérarchies de class / record, OneOf, les Source Generators
- Organiser dès maintenant les valeurs de retour et les états sous forme de types « exactement l’un de ces cas », afin de faciliter une future migration vers le union de C#
Autrement dit, il n’est pas nécessaire d’attendre le union de C# pour commencer, dès aujourd’hui, une conception de type ADT.
Au contraire, organiser dès maintenant les types Result, Option, les types d’état et les types d’événement de domaine facilitera la migration future vers cette fonctionnalité du langage.
11. Le type Option : exprimer « l’absence » au lieu de null
Un exemple emblématique de type de données algébrique est Option<T>.
Option<T> exprime l’un des deux cas suivants :
Some(value)
None
En C#, on exprime souvent « l’absence » avec null, mais null pose problème car il est invisible dans le type.
User user = repository.FindById(id);
// L'appelant doit se souvenir que user peut être null
Console.WriteLine(user.Name);
Avec Option<User>, la possibilité de « ne pas être trouvé » apparaît dans le type.
Voici une implémentation simple, utilisable même sur .NET Framework.
public abstract class Option<T>
{
private Option()
{
}
public sealed class Some : Option<T>
{
internal Some(T value)
{
Value = value;
}
public T Value { get; }
}
public sealed class None : Option<T>
{
internal None()
{
}
}
private static readonly None NoneValue = new None();
public static Option<T> Of(T value)
{
if (object.Equals(value, null))
{
return NoneValue;
}
return new Some(value);
}
public static Option<T> Empty()
{
return NoneValue;
}
public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
{
if (some == null) throw new ArgumentNullException(nameof(some));
if (none == null) throw new ArgumentNullException(nameof(none));
var s = this as Some;
if (s != null) return some(s.Value);
return none();
}
}
Côté appelant, cela donne :
Option<User> user = repository.FindById(id);
string displayName = user.Match(
some: u => u.Name,
none: () => "Invité");
Il n’est pas nécessaire d’éliminer complètement null.
Les API .NET existantes, les bases de données et le JSON produisent tous du null.
Mais à l’intérieur de la logique de domaine, Option<T> rend souvent l’intention plus claire que null.
Option<T> convient particulièrement bien à des méthodes comme celles-ci :
Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);
L’idée n’est pas seulement d’ajouter Try au nom de la méthode, mais aussi d’exprimer la « possibilité d’absence » dans le type de retour lui-même.
12. Le type Result : renvoyer les échecs attendus sous forme de type
Un autre type fréquemment utilisé est Result<TSuccess, TError>.
Il exprime l’un des deux cas suivants :
Success(value)
Failure(error)
Les exceptions conviennent bien aux échecs imprévus, ou aux échecs que l’on ne souhaite pas faire transiter par le flux de contrôle normal. À l’inverse, les échecs qui se produisent couramment dans le métier sont souvent plus lisibles lorsqu’ils sont renvoyés sous forme de type.
Par exemple, dans un flux de connexion, les échecs suivants sont tous attendus :
- L’utilisateur n’existe pas
- Le mot de passe est incorrect
- Le compte est verrouillé
- L’authentification multifacteur est requise
Si on les exprime uniquement avec des exceptions, l’appelant finit par écrire du branchement métier à l’intérieur d’un bloc catch.
try
{
var session = auth.Login(userName, password);
return Ok(session);
}
catch (InvalidPasswordException)
{
return Unauthorized();
}
catch (AccountLockedException)
{
return Forbid();
}
Cela fonctionne avec des exceptions, mais le branchement métier a tendance à se noyer dans le traitement des exceptions.
Exprimé en style ADT, cela donne :
public abstract class LoginResult
{
private LoginResult()
{
}
public sealed class Succeeded : LoginResult
{
internal Succeeded(Session session)
{
Session = session;
}
public Session Session { get; }
}
public sealed class InvalidPassword : LoginResult
{
internal InvalidPassword()
{
}
}
public sealed class AccountLocked : LoginResult
{
internal AccountLocked(DateTimeOffset until)
{
Until = until;
}
public DateTimeOffset Until { get; }
}
public sealed class MfaRequired : LoginResult
{
internal MfaRequired(string challengeId)
{
ChallengeId = challengeId;
}
public string ChallengeId { get; }
}
public static LoginResult Success(Session session)
=> new Succeeded(session);
public static LoginResult WrongPassword()
=> new InvalidPassword();
public static LoginResult Locked(DateTimeOffset until)
=> new AccountLocked(until);
public static LoginResult RequireMfa(string challengeId)
=> new MfaRequired(challengeId);
public T Match<T>(
Func<Succeeded, T> succeeded,
Func<InvalidPassword, T> invalidPassword,
Func<AccountLocked, T> accountLocked,
Func<MfaRequired, T> mfaRequired)
{
if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));
var s = this as Succeeded;
if (s != null) return succeeded(s);
var i = this as InvalidPassword;
if (i != null) return invalidPassword(i);
var l = this as AccountLocked;
if (l != null) return accountLocked(l);
var m = this as MfaRequired;
if (m != null) return mfaRequired(m);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Sous cette forme, l’appelant implémente son code en ayant sous les yeux « les résultats possibles du flux de connexion ».
var result = auth.Login(userName, password);
return result.Match(
succeeded => Ok(succeeded.Session),
invalidPassword => Unauthorized(),
accountLocked => StatusCode(423),
mfaRequired => Accepted(new { mfaRequired.ChallengeId }));
L’idée n’est pas d’abandonner les exceptions.
Répartir les rôles : le branchement métier attendu passe par Result, l’anomalie imprévue passe par une exception.
Cela seul améliore déjà nettement la lisibilité de la couche des services applicatifs et de la couche API.
13. Exprimer les transitions d’état avec des types
Les ADT sont utiles non seulement pour les valeurs de retour, mais aussi pour représenter un état.
Prenons par exemple l’état d’une commande.
public enum OrderStatus
{
Draft,
Submitted,
Paid,
Shipped,
Cancelled
}
Avec un enum seul, il est difficile d’exprimer les données nécessaires à chaque état.
Drafta besoin d’un créateurSubmitteda besoin d’un horodatage de soumissionPaida besoin d’un numéro de paiementShippeda besoin d’un numéro de suiviCancelleda besoin d’un motif d’annulation
Si on essaie de les exprimer avec OrderStatus et des propriétés séparées, les propriétés nullable se multiplient à nouveau.
public sealed class Order
{
public OrderStatus Status { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public string PaymentNo { get; set; }
public string TrackingNo { get; set; }
public string CancelReason { get; set; }
}
Avec cette conception, on peut créer un état où Status == Draft alors que TrackingNo est renseigné.
En style ADT, l’état lui-même devient un type.
public abstract class OrderState
{
private OrderState()
{
}
public sealed class Draft : OrderState
{
internal Draft(UserId createdBy)
{
CreatedBy = createdBy;
}
public UserId CreatedBy { get; }
}
public sealed class Submitted : OrderState
{
internal Submitted(DateTimeOffset submittedAt)
{
SubmittedAt = submittedAt;
}
public DateTimeOffset SubmittedAt { get; }
}
public sealed class Paid : OrderState
{
internal Paid(string paymentNo)
{
PaymentNo = paymentNo;
}
public string PaymentNo { get; }
}
public sealed class Shipped : OrderState
{
internal Shipped(string trackingNo)
{
TrackingNo = trackingNo;
}
public string TrackingNo { get; }
}
public sealed class Cancelled : OrderState
{
internal Cancelled(string reason)
{
Reason = reason;
}
public string Reason { get; }
}
}
La commande porte un OrderState.
public sealed class Order
{
public OrderId Id { get; }
public OrderState State { get; private set; }
public Order(OrderId id, UserId createdBy)
{
Id = id;
State = new OrderState.Draft(createdBy);
}
}
Les transitions d’état sont ensuite confinées dans des méthodes.
public void Submit(IClock clock)
{
if (!(State is OrderState.Draft))
{
throw new InvalidOperationException("Seules les commandes à l'état brouillon peuvent être soumises.");
}
State = new OrderState.Submitted(clock.Now);
}
public void MarkAsPaid(string paymentNo)
{
if (!(State is OrderState.Submitted))
{
throw new InvalidOperationException("Seules les commandes soumises peuvent être marquées comme payées.");
}
State = new OrderState.Paid(paymentNo);
}
Sous cette forme, les données propres à chaque état et les règles de transition deviennent faciles à lire.
Bien sûr, lors de la persistance, il arrive que l’on sépare encore les données entre OrderStatus et des colonnes auxiliaires.
Même dans ce cas, il suffit de traiter l’état comme OrderState à l’intérieur du domaine, et de le convertir à la frontière avec la base de données.
Représentation en base de données
status = "Paid"
payment_no = "PAY-001"
Représentation à l'intérieur du domaine
OrderState.Paid("PAY-001")
Il n’est pas nécessaire d’affaiblir le modèle de domaine pour se conformer au schéma de la base de données.
14. Convertir en DTO aux frontières de l’API
Les types de style ADT sont très pratiques à l’intérieur du domaine.
En revanche, du côté des API JSON, des bases de données, des files de messages, d’OpenAPI et des intégrations externes, un peu de vigilance est nécessaire.
Supposons que l’on expose directement cet ADT en JSON :
public abstract record PaymentResult
{
public sealed record Succeeded(string ReceiptNo) : PaymentResult;
public sealed record Rejected(string Reason) : PaymentResult;
public sealed record NetworkFailure(string Message) : PaymentResult;
}
En JSON, on pourrait vouloir une forme comme celle-ci :
{
"type": "succeeded",
"receiptNo": "R-001"
}
Et pour un échec, une forme comme celle-ci :
{
"type": "rejected",
"reason": "card_expired"
}
Ce type est un discriminant côté JSON.
L’ADT du domaine et la représentation JSON se ressemblent, mais ce ne sont pas la même chose.
C’est pourquoi convertir en DTO à la frontière externe est une conception plus sûre.
public sealed class PaymentResultDto
{
public string Type { get; set; }
public string ReceiptNo { get; set; }
public string Reason { get; set; }
public string Message { get; set; }
}
Un traitement de conversion crée le DTO pour chaque cas de l’ADT.
public static PaymentResultDto ToDto(PaymentResult result)
{
return result switch
{
PaymentResult.Succeeded x => new PaymentResultDto
{
Type = "succeeded",
ReceiptNo = x.ReceiptNo
},
PaymentResult.Rejected x => new PaymentResultDto
{
Type = "rejected",
Reason = x.Reason
},
PaymentResult.NetworkFailure x => new PaymentResultDto
{
Type = "network_failure",
Message = x.Message
},
_ => throw new InvalidOperationException("Résultat de paiement inconnu.")
};
}
On peut bien sûr aussi utiliser la sérialisation polymorphe de System.Text.Json ou des convertisseurs personnalisés.
Mais pour une API maintenue sur le long terme, il est souvent plus sûr de ne pas coupler étroitement la forme du JSON à la structure interne du type de domaine.
Voici la séparation recommandée :
À l'intérieur du domaine
PaymentResult.Succeeded
PaymentResult.Rejected
PaymentResult.NetworkFailure
À la frontière de l'API
PaymentResultDto
type: "succeeded" | "rejected" | "network_failure"
Laisser le type de domaine se concentrer sur la représentation du métier, et stabiliser la représentation externe via un DTO.
Avec cette séparation, on peut continuer à améliorer l’intérieur du domaine tout en préservant la compatibilité de l’API.
15. Bénéfice 1 : les états invalides deviennent difficiles à construire
Le plus grand bénéfice des ADT est de rendre difficile la construction d’états invalides.
Par exemple, un type comme celui-ci permet de créer facilement des combinaisons invalides.
public sealed class Reservation
{
public bool IsCancelled { get; set; }
public DateTimeOffset? CancelledAt { get; set; }
public string CancelReason { get; set; }
public DateTimeOffset? ConfirmedAt { get; set; }
}
Ce type permet de construire des états comme ceux-ci :
CancelledAtest renseigné alors que la réservation n’est pas annulée- La réservation est annulée mais
CancelReasonest absent ConfirmedAtest renseigné alors que la réservation est annulée- Un horodatage de confirmation existe avant même la confirmation
En style ADT, on peut séparer les données nécessaires à chaque état.
public abstract class ReservationState
{
private ReservationState()
{
}
public sealed class Requested : ReservationState
{
internal Requested(DateTimeOffset requestedAt)
{
RequestedAt = requestedAt;
}
public DateTimeOffset RequestedAt { get; }
}
public sealed class Confirmed : ReservationState
{
internal Confirmed(DateTimeOffset confirmedAt)
{
ConfirmedAt = confirmedAt;
}
public DateTimeOffset ConfirmedAt { get; }
}
public sealed class Cancelled : ReservationState
{
internal Cancelled(DateTimeOffset cancelledAt, string reason)
{
CancelledAt = cancelledAt;
Reason = reason;
}
public DateTimeOffset CancelledAt { get; }
public string Reason { get; }
}
}
Ainsi, seul l’état annulé porte l’horodatage et le motif d’annulation.
Au lieu de vérifier après coup les combinaisons invalides, on les élimine dès la conception.
Cela compte énormément aussi du point de vue des tests.
Quand bool et les propriétés nullable se multiplient, le nombre de combinaisons explose.
Avec un ADT, les cas à tester s’organisent en « cas définis ».
16. Bénéfice 2 : faire prendre conscience à l’appelant des cas non traités
Les ADT montrent à l’appelant « quels cas cette valeur peut prendre ».
Par exemple, en regardant la valeur de retour suivante, l’appelant comprend qu’il doit traiter Found, NotFound et Forbidden.
public abstract class GetDocumentResult
{
private GetDocumentResult()
{
}
public sealed class Found : GetDocumentResult
{
internal Found(Document document)
{
Document = document;
}
public Document Document { get; }
}
public sealed class NotFound : GetDocumentResult
{
internal NotFound(DocumentId id)
{
Id = id;
}
public DocumentId Id { get; }
}
public sealed class Forbidden : GetDocumentResult
{
internal Forbidden(UserId userId)
{
UserId = userId;
}
public UserId UserId { get; }
}
public static GetDocumentResult DocumentFound(Document document)
=> new Found(document);
public static GetDocumentResult DocumentNotFound(DocumentId id)
=> new NotFound(id);
public static GetDocumentResult AccessForbidden(UserId userId)
=> new Forbidden(userId);
public T Match<T>(
Func<Found, T> found,
Func<NotFound, T> notFound,
Func<Forbidden, T> forbidden)
{
if (found == null) throw new ArgumentNullException(nameof(found));
if (notFound == null) throw new ArgumentNullException(nameof(notFound));
if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));
var f = this as Found;
if (f != null) return found(f);
var n = this as NotFound;
if (n != null) return notFound(n);
var d = this as Forbidden;
if (d != null) return forbidden(d);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
En renvoyant simplement null, on ne peut pas savoir si le document « n’existe pas », si « l’accès est refusé » ou si « le chargement a échoué ».
Avec des exceptions seules, il devient difficile de savoir quelles exceptions sont attendues au niveau métier.
Exprimé comme GetDocumentResult, la signature de la méthode devient la spécification.
GetDocumentResult GetDocument(UserId userId, DocumentId documentId);
Cette méthode ne se contente pas de renvoyer un document.
Elle porte un contrat d’API : elle renvoie l’un des cas « trouvé », « introuvable » ou « accès refusé ».
De plus, avec Match, il devient facile de repérer les cas non traités.
return result.Match(
found => Ok(found.Document),
notFound => NotFound(),
forbidden => Forbid());
Lorsqu’on ajoute un nouveau cas, si le nombre de paramètres de Match augmente, on repère facilement, dès la compilation, les appelants qui n’ont pas été mis à jour.
C’est extrêmement précieux pour la maintenance à long terme.
17. Bénéfice 3 : le vocabulaire du domaine reste dans le code
Si l’on exprime un état uniquement avec bool, int, string et null, le sens métier disparaît du code.
return false;
Que signifie ce false ?
- Non trouvé
- Saisie invalide
- Accès non autorisé
- Un service externe était en panne
- Déjà traité
Sans en connaître le contexte, l’appelant ne peut pas le savoir.
Avec un ADT, le vocabulaire métier subsiste sous forme de type.
return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);
Cette différence est considérable.
Dans les revues de code, dans les journaux, dans les tests, le vocabulaire métier devient visible.
Les noms de tests, par exemple, deviennent naturels.
[Fact]
public void Resoumettre_une_commande_deja_soumise_renvoie_AlreadySubmitted()
{
var result = service.Submit(orderId);
Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}
Ce n’est pas une simple technique d’implémentation : c’est une manière de conserver la spécification métier dans le code.
18. Bénéfice 4 : réduire l’usage excessif des exceptions
Les exceptions de .NET sont puissantes.
Mais lorsqu’on transforme en exceptions jusqu’aux branchements qui se produisent couramment dans le métier, la lisibilité du traitement peut en souffrir.
Prenons par exemple l’allocation de stock.
Une rupture de stock n’est pas une anomalie du point de vue du système. C’est, sur le plan métier, un résultat parfaitement normal.
public abstract class ReserveStockResult
{
private ReserveStockResult()
{
}
public sealed class Reserved : ReserveStockResult
{
internal Reserved(ReservationId reservationId)
{
ReservationId = reservationId;
}
public ReservationId ReservationId { get; }
}
public sealed class OutOfStock : ReserveStockResult
{
internal OutOfStock(Sku sku, int requested, int available)
{
Sku = sku;
Requested = requested;
Available = available;
}
public Sku Sku { get; }
public int Requested { get; }
public int Available { get; }
}
public T Match<T>(
Func<Reserved, T> reserved,
Func<OutOfStock, T> outOfStock)
{
if (reserved == null) throw new ArgumentNullException(nameof(reserved));
if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));
var r = this as Reserved;
if (r != null) return reserved(r);
var o = this as OutOfStock;
if (o != null) return outOfStock(o);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Exprimé ainsi, la rupture de stock devient un résultat normal appelé OutOfStock.
var result = stock.Reserve(sku, quantity);
return result.Match(
reserved => Ok(reserved.ReservationId),
outOfStock => Conflict(new
{
sku = outOfStock.Sku.Value,
requested = outOfStock.Requested,
available = outOfStock.Available
}));
En revanche, des situations comme une coupure de connexion à la base de données, un fichier de configuration corrompu, ou une incohérence inattendue, peuvent tout à fait rester des exceptions.
Comme critère, cette ligne de partage est réaliste en pratique :
Ce que l'appelant doit traiter comme un branchement normal
=> renvoyer un Result / ADT
Ce dont le traitement normal ne peut pas se remettre
=> lever une exception
Avec cette répartition, on évite que le try-catch ne devienne un substitut au branchement métier.
19. Bénéfice 5 : les tests deviennent plus faciles à écrire
Avec un ADT, les cas à tester deviennent explicites.
Supposons que l’on ait le type de résultat suivant :
SubmitOrderResult =
Submitted(orderId)
ou AlreadySubmitted(orderId)
ou InvalidOrder(reason)
ou CreditLimitExceeded(limit)
Dans ce cas, les tests se répartissent naturellement par cas.
Une commande valide renvoie Submitted
Une commande déjà soumise renvoie AlreadySubmitted
Une commande invalide renvoie InvalidOrder
Un dépassement du plafond de crédit renvoie CreditLimitExceeded
Lorsque l’état est exprimé par des combinaisons de propriétés nullable, le code de test doit lui aussi comprendre « quelles combinaisons sont valides ».
Avec un ADT, les cas eux-mêmes deviennent les angles de test.
Les données de test aussi deviennent plus faciles à créer.
var result = SubmitOrderResult.CreditLimitExceeded(limit);
Cette seule ligne crée une donnée qui signifie « dépassement du plafond de crédit ».
C’est bien plus explicite que d’assembler un objet plausible en combinant Status, ErrorCode, Message et Limit.
20. Stratégie d’adoption pour .NET Framework
Lorsqu’on introduit une conception de style ADT dans un système .NET Framework existant, mieux vaut éviter de tout changer d’un coup.
Nous recommandons de commencer par les valeurs de retour. Dans le code existant, on recherche des cas comme ceux-ci :
- Une méthode
bool TryXxx(...)qui a désormais aussi besoin d’un motif d’échec - Une méthode qui renvoie
nullalors qu’il existe plusieurs raisons de « ne pas trouver » - Un
enum Statusaccompagné d’un nombre croissant de propriétés auxiliaires nullable - Un branchement métier exprimé au moyen d’exceptions
- Des comparaisons de chaînes de caractères sur
ErrorCodequi se répandent
Ce sont des endroits où la conversion en ADT apporte rapidement ses fruits.
Ensuite, on crée un type de résultat dédié.
public abstract class RegisterMemberResult
{
private RegisterMemberResult()
{
}
public sealed class Registered : RegisterMemberResult
{
internal Registered(MemberId memberId)
{
MemberId = memberId;
}
public MemberId MemberId { get; }
}
public sealed class DuplicateEmail : RegisterMemberResult
{
internal DuplicateEmail(string email)
{
Email = email;
}
public string Email { get; }
}
public sealed class InvalidInvitationCode : RegisterMemberResult
{
internal InvalidInvitationCode(string code)
{
Code = code;
}
public string Code { get; }
}
public T Match<T>(
Func<Registered, T> registered,
Func<DuplicateEmail, T> duplicateEmail,
Func<InvalidInvitationCode, T> invalidInvitationCode)
{
if (registered == null) throw new ArgumentNullException(nameof(registered));
if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));
var r = this as Registered;
if (r != null) return registered(r);
var d = this as DuplicateEmail;
if (d != null) return duplicateEmail(d);
var i = this as InvalidInvitationCode;
if (i != null) return invalidInvitationCode(i);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Puis, aux frontières de l’API existante, on convertit immédiatement vers un DTO ou vers l’ancien format.
var result = service.Register(command);
return result.Match(
registered => new RegisterMemberResponse
{
Success = true,
MemberId = registered.MemberId.Value
},
duplicate => new RegisterMemberResponse
{
Success = false,
ErrorCode = "DuplicateEmail",
ErrorMessage = duplicate.Email + " est déjà utilisé."
},
invalidCode => new RegisterMemberResponse
{
Success = false,
ErrorCode = "InvalidInvitationCode",
ErrorMessage = "Le code d'invitation n'est pas valide."
});
On peut renforcer d’abord la logique interne, sans avoir à changer immédiatement l’interface externe.
Cela compte énormément dans les systèmes existants.
Contraintes de l'API externe ou de l'écran
Conserver le format de réponse existant
Logique de domaine interne
Traiter en toute sécurité avec des types de style ADT
La seule conversion à la frontière permet déjà de nettoyer considérablement le branchement interne.
21. Créer une bibliothèque partagée avec .NET Standard
Pour une bibliothèque utilisée à la fois depuis .NET Framework et depuis le .NET actuel, .NET Standard constitue une option.
Si l’on privilégie une large compatibilité, .NET Standard 2.0 est un candidat réaliste.
Par exemple, on place le modèle de domaine et les types de résultat dans une bibliothèque structurée ainsi :
MyApp.Domain
TargetFramework : netstandard2.0
MyApp.LegacyWeb
TargetFramework : net472
Référence MyApp.Domain
MyApp.Api
TargetFramework : net8.0
Référence MyApp.Domain
Avec cette structure, l’ancienne application .NET Framework et la nouvelle application .NET peuvent facilement partager les mêmes types de domaine.
Cependant, si l’on cible .NET Standard 2.0, il faut éviter de dépendre trop fortement des nouvelles API C# / .NET.
Par exemple, ces choix de conception sont souvent à éviter dans une bibliothèque partagée :
- Dépendre fortement de
recordou deinit - Utiliser directement des API de .NET 6 et versions ultérieures
- Exposer largement du code qui suppose l’usage de Source Generators
- Placer des types spécifiques à ASP.NET Core dans la couche de domaine
Dans une bibliothèque partagée, centrer la conception sur des classes simples, des objets de valeur et des types de résultat de style ADT la rend utilisable durablement.
public abstract class PaymentResult
{
private PaymentResult()
{
}
// Exprimé comme une classe ordinaire, à l'aise aussi bien sur .NET Framework que sur .NET
}
Dans les couches applicatives dédiées au .NET récent, on peut utiliser librement les records et les expressions switch.
Couche de domaine partagée
Types ordinaires lisibles même dans les anciens environnements
Nouvelle couche applicative
Tirer parti des records / du pattern matching / des minimal API, etc.
Cette séparation facilite l’équilibre entre les actifs existants et les nouveaux développements.
22. Jusqu’où faut-il aller avec les ADT ?
Les ADT sont pratiques, mais cela ne veut pas dire qu’il faut tout transformer en ADT.
Ils conviennent aux valeurs dont l’ensemble des cas est, sur le plan métier, à peu près fermé. On pense par exemple à :
- Les résultats de traitement
- Les résultats de validation de saisie
- Les états de commande
- Les résultats de paiement
- Les résultats d’authentification
- Les résultats d’appel à un service externe
- Les événements de domaine
- Les types de commande
- Les états d’écran
À l’inverse, certains cas demandent de la prudence :
- Ce dont le nombre de types augmente de l’extérieur via des plugins
- Ce dont le nombre de types augmente par définition de l’utilisateur
- Ce qui, en tant que donnée de référence en base, s’enrichit en cours d’exploitation
- Les types d’intégration à un framework conçus pour l’extension par héritage
- Les DTO de CRUD simples
Si la conception permet aux cas d’augmenter depuis l’extérieur, une interface ou une hiérarchie d’héritage ordinaire convient mieux qu’un ADT fermé.
Par exemple, si les formats de sortie d’un rapport augmentent par le biais de plugins, la conception suivante est naturelle :
public interface IReportExporter
{
string FormatName { get; }
void Export(Report report, Stream output);
}
Dans ce cas, figer les choses dans un type somme fermé comme PdfExporter | ExcelExporter | CsvExporter rendrait l’extension externe difficile.
Les ADT excellent dans un « monde fermé ».
L’ensemble est-il réellement fermé sur le plan métier ? Pourrait-il s’enrichir de l’extérieur à l’avenir ?
C’est ce discernement qui compte.
23. Choisir entre enum et ADT
enum n’est pas un mauvais choix.
enum convient lorsque chaque cas ne porte aucune donnée supplémentaire et qu’une simple étiquette suffit. En voici quelques exemples :
public enum Gender
{
Unknown,
Male,
Female,
Other
}
Ou encore quelque chose comme les niveaux de journalisation :
public enum LogLevel
{
Trace,
Debug,
Information,
Warning,
Error,
Critical
}
En revanche, si chaque cas a besoin de données différentes, il faut envisager un type de style ADT.
enum PaymentStatus
Succeeded
Rejected
Failed
ADT PaymentResult
Succeeded(receiptNo)
Rejected(reason)
Failed(message)
Le critère de distinction est simple :
Il suffit de connaître le cas
=> enum
Chaque cas porte des données différentes
=> ADT
Chaque cas a un comportement ou des contraintes différents
=> ADT ou hiérarchie de class
Quand un enum + un ensemble de propriétés nullable commence à s’étoffer, c’est le signal qu’il est temps de passer à un ADT.
24. Choisir entre bool et ADT
bool n’est pas non plus un mauvais choix.
Si le sens se résume vraiment à un simple oui / non, bool suffit.
bool IsEnabled { get; }
bool IsDeleted { get; }
Mais s’il existe plusieurs motifs d’échec possibles, bool devient insuffisant.
bool TryCreateUser(CreateUserCommand command);
Cette méthode ne dit rien sur le motif de l’échec.
On peut compenser avec des paramètres out :
bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);
Mais cela se complique progressivement.
Dans ce cas, un type de résultat se lit mieux.
CreateUserResult CreateUser(CreateUserCommand command);
L’appelant peut alors traiter, sous forme de type, non seulement le succès et l’échec, mais aussi le genre d’échec.
return result.Match(
created => Ok(created.User),
duplicate => Conflict(),
weak => BadRequest(),
failure => StatusCode(500));
Voici les critères :
Vraiment binaire, sans information supplémentaire nécessaire
=> bool
Binaire, mais une valeur de succès ou un motif d'échec est nécessaire
=> Result
Trois cas ou plus, ou des données différentes selon le cas
=> ADT
25. La différence entre l’héritage et les ADT
Lorsqu’on construit un type de style ADT en C#, il ressemble beaucoup, en apparence, à un héritage ordinaire.
public abstract class PaymentResult
{
}
public sealed class Succeeded : PaymentResult
{
}
public sealed class Rejected : PaymentResult
{
}
Mais l’objectif diffère légèrement.
L’héritage orienté objet classique sert le plus souvent à substituer un comportement.
public abstract class Shape
{
public abstract double Area();
}
public sealed class Circle : Shape
{
public override double Area() => ...;
}
L’héritage de style ADT, à l’inverse, sert à exprimer « les formes possibles des données ».
public abstract class PaymentResult
{
public sealed class Succeeded : PaymentResult
{
public string ReceiptNo { get; }
}
public sealed class Rejected : PaymentResult
{
public string Reason { get; }
}
}
Il ne s’agit pas de dire lequel des deux est « le bon ».
Si l’on veut placer la logique du côté de chaque cas, le polymorphisme classique convient.
public abstract class Notification
{
public abstract void Send();
}
Si l’on veut que l’appelant fasse son branchement en voyant tous les cas, l’ADT associé au pattern matching / à Match convient.
return notification.Match(
email => SendEmail(email),
sms => SendSms(sms),
push => SendPush(push));
Dans les applications métier, une répartition claire consiste à utiliser un ADT pour les valeurs de retour et les états, et une interface pour le comportement interchangeable.
26. Ne pas disperser le pattern matching partout
Une fois que l’on commence à utiliser des ADT, on est tenté d’écrire des switch ou des Match un peu partout.
Mais si le même branchement se retrouve dupliqué à de nombreux endroits, chaque nouveau cas multiplie les points à modifier.
Supposons que PaymentResult fasse l’objet d’un switch à divers endroits :
Conversion de la réponse d'API
Sortie de journal
Génération du message affiché à l'écran
Enregistrement de métriques
Génération du journal d'audit
Ajouter un cas oblige alors à corriger chacun de ces switch.
Cela reste parfois inévitable, mais concentrer autant que possible la responsabilité du branchement facilite la maintenance.
public static class PaymentResultMapper
{
public static PaymentResultDto ToDto(PaymentResult result)
{
return result.Match(
succeeded => ...,
rejected => ...,
failure => ...);
}
public static string ToLogMessage(PaymentResult result)
{
return result.Match(
succeeded => ...,
rejected => ...,
failure => ...);
}
}
Il est aussi parfois préférable de faire porter le traitement par le cas lui-même plutôt que de brancher à chaque fois.
public abstract class PaymentResult
{
public abstract bool IsSuccess { get; }
}
Cependant, si l’on fait porter trop de traitement par les cas, le type de domaine commence à connaître les contraintes de l’API ou de l’interface utilisateur.
Ce genre de traitement vaut mieux, la plupart du temps, ne pas être placé directement dans le type de domaine :
- La conversion vers un code de statut HTTP
- La conversion vers un DTO JSON
- Les messages affichés à l’écran
- Le format des journaux
- La représentation destinée à OpenAPI
Le type de domaine exprime le sens métier. La conversion aux frontières se place dans un Mapper.
Garder cette séparation en tête rend les ADT plus faciles à maintenir sur le long terme.
27. Convention de nommage
Pour un type de style ADT, le nom compte.
Des noms génériques comme Result, Error ou Response seuls diluent le sens.
Voici des noms fréquemment utilisés :
CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState
Les noms de cas se rapprochent du vocabulaire métier.
Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked
Avec des noms comme Error1, Error2 ou simplement Failed, l’appelant a du mal à en comprendre le sens.
De même, autant que possible, les données portées par chaque cas devraient être des types de niveau métier.
public sealed class CreditLimitExceeded : SubmitOrderResult
{
public Money Limit { get; }
public Money RequestedAmount { get; }
}
Utiliser directement decimal ou string fonctionne, mais les combiner avec des objets de valeur comme Money, Email, UserId ou OrderId rend l’intention encore plus explicite.
Les ADT et les objets de valeur se marient bien.
Objets de valeur
Expriment le sens et les contraintes d'une valeur unique
ADT
Expriment les différentes formes possibles
Combiner les deux facilite l’enfermement des règles métier dans les types.
28. Faire attention au versionnement
Comme les ADT rendent explicite l’ensemble des cas, l’ajout d’un cas a un impact sur l’appelant.
C’est à la fois un bénéfice et un point de vigilance.
Pour du code interne, une erreur de compilation lors de l’ajout d’un cas est bienvenue : elle permet de repérer les endroits non traités.
En revanche, pour un type fourni à l’extérieur, en tant que package NuGet ou API publique, l’ajout d’un cas peut avoir un impact proche d’un changement cassant.
Supposons qu’un utilisateur de la bibliothèque ait écrit un traitement exhaustif de ce type :
var text = result.Match(
success => ...,
validationError => ...,
permissionDenied => ...);
Si la bibliothèque ajoute un cas RateLimited et modifie en conséquence la signature de Match, le code de l’utilisateur ne compile plus.
C’est sûr, mais cela a un impact en termes de compatibilité de l’API publique.
C’est pourquoi, pour une bibliothèque publique, on raisonne ainsi :
- Si l’on accepte d’ajouter des cas, monter le numéro de version et le traiter comme un changement cassant
- Si l’on veut permettre aux utilisateurs externes un traitement de type
default, choisir une conception autre qu’un ADT fermé - Être strict dans le domaine interne, et utiliser des DTO ainsi que des contrats versionnés pour l’API externe
À l’intérieur d’une application métier, une erreur de compilation lors de l’ajout d’un cas est un cadeau.
Pour une API publique, il faut aussi penser conjointement à la conception de la compatibilité.
29. À propos des performances
Une conception de style ADT peut, au nom de l’expressivité, multiplier le nombre d’objets.
Lorsqu’on utilise une hiérarchie de classes sur .NET Framework, un objet est alloué pour chaque cas.
return PaymentResult.Success(receiptNo);
Dans une application métier ordinaire, cela ne pose souvent pas de problème significatif.
Cependant, il faut être prudent dans des contextes comme ceux-ci :
- Du code de bas niveau appelé à très haute fréquence
- Un traitement en flux gérant un volume énorme d’événements
- Des jeux vidéo ou du traitement temps réel
- Un traitement où l’on veut réduire les allocations de manière extrême
- Un traitement qui stocke un très grand nombre d’ADT dans une collection gigantesque
Lorsque la performance compte, plusieurs options existent :
- Utiliser un type Result basé sur un struct
- Envisager les unions discriminées struct de F#
- Réduire les allocations avec un Source Generator
- Utiliser un enum et des champs dédiés sur le chemin critique, et convertir en ADT à la frontière
- Mesurer avant d’optimiser
Il n’est pas nécessaire d’optimiser à outrance dès le départ.
Dans la plupart des systèmes métier, la clarté de conception qu’apporte un ADT vaut bien plus que le léger coût de création d’objets.
Cela dit, là où les exigences de performance sont strictes, la conception et la mesure doivent aller de pair.
30. Un exemple de refactoring de code existant
Pour finir, examinons comment transformer en style ADT un morceau de code existant typique.
Voici le code d’origine :
public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
errorCode = null;
var stock = stockRepository.Find(sku);
if (stock == null)
{
errorCode = "SKU_NOT_FOUND";
return false;
}
if (stock.Available < quantity)
{
errorCode = "OUT_OF_STOCK";
return false;
}
stock.Reserve(quantity);
return true;
}
Dans ce code, le motif de l’échec est exprimé par une string.
L’appelant doit comparer des chaînes de caractères.
string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
if (errorCode == "SKU_NOT_FOUND")
{
...
}
else if (errorCode == "OUT_OF_STOCK")
{
...
}
}
Transformons cela en type de résultat.
public abstract class ReserveStockResult
{
private ReserveStockResult()
{
}
public sealed class Reserved : ReserveStockResult
{
internal Reserved(ReservationId reservationId)
{
ReservationId = reservationId;
}
public ReservationId ReservationId { get; }
}
public sealed class SkuNotFound : ReserveStockResult
{
internal SkuNotFound(Sku sku)
{
Sku = sku;
}
public Sku Sku { get; }
}
public sealed class OutOfStock : ReserveStockResult
{
internal OutOfStock(Sku sku, int requested, int available)
{
Sku = sku;
Requested = requested;
Available = available;
}
public Sku Sku { get; }
public int Requested { get; }
public int Available { get; }
}
public static ReserveStockResult Success(ReservationId reservationId)
=> new Reserved(reservationId);
public static ReserveStockResult NotFound(Sku sku)
=> new SkuNotFound(sku);
public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
=> new OutOfStock(sku, requested, available);
public T Match<T>(
Func<Reserved, T> reserved,
Func<SkuNotFound, T> skuNotFound,
Func<OutOfStock, T> outOfStock)
{
var r = this as Reserved;
if (r != null) return reserved(r);
var n = this as SkuNotFound;
if (n != null) return skuNotFound(n);
var o = this as OutOfStock;
if (o != null) return outOfStock(o);
throw new InvalidOperationException("Résultat de réservation de stock inconnu.") ;
}
}
La méthode de service devient :
public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
var stock = stockRepository.Find(sku);
if (stock == null)
{
return ReserveStockResult.NotFound(sku);
}
if (stock.Available < quantity)
{
return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
}
var reservationId = stock.Reserve(quantity);
return ReserveStockResult.Success(reservationId);
}
L’appelant peut abandonner la comparaison de chaînes de caractères.
var result = service.ReserveStock(sku, quantity);
return result.Match(
reserved => Ok(new { reserved.ReservationId }),
notFound => NotFound(new { sku = notFound.Sku.Value }),
outOfStock => Conflict(new
{
sku = outOfStock.Sku.Value,
requested = outOfStock.Requested,
available = outOfStock.Available
}));
Le point clé de ce refactoring est que l’on peut déplacer le sens interne vers les types sans changer le comportement externe.
D’abord, renforcer la valeur de retour.
Ensuite, orienter l’appelant vers Match.
Enfin, réduire progressivement les codes d’erreur sous forme de chaîne et les propriétés auxiliaires nullable.
Dans cet ordre, on peut l’introduire progressivement, même dans un système existant.
31. Liste de contrôle pour l’adoption
Lorsqu’on crée un type de style ADT, il convient de vérifier les points suivants :
Ce type représente-t-il « exactement l'un de ces cas » ?
L'ensemble des cas est-il fermé sur le plan métier ?
Chaque cas a-t-il besoin de données différentes ?
bool / enum / null / le code d'erreur en string n'a-t-il pas déjà perdu son sens ?
Veut-on que l'appelant ait conscience de devoir traiter tous les cas ?
Cela affecte-t-il la compatibilité d'une API publique ?
Existe-t-il une politique de conversion vers le JSON / la base de données / le DTO d'écran ?
Si .NET Framework est aussi une cible, une classe ordinaire suffit-elle ?
Si l'on cible uniquement le .NET actuel, un record ou un Source Generator vaut-il le coup ?
On peut choisir la stratégie d’implémentation ainsi :
Projet F#
Utiliser les unions discriminées de F#
C# sur .NET Framework
abstract class + constructeur private + classes sealed imbriquées + Match
C# sur .NET 5 et versions ultérieures
abstract record + cas sealed record + pattern matching
Valeur de retour locale
Une bibliothèque comme OneOf
Réduire le code répétitif sur le .NET actuel
Une bibliothèque basée sur un Source Generator
Exploration pour l'avenir
L'aperçu union de C# 15
Quelle que soit l’approche choisie, l’objectif reste le même :
Protéger avec des types ce qu’on protégeait autrefois avec des commentaires.
C’est là l’intérêt le plus fondamental des ADT.
32. Conclusion
Les types de données algébriques ne sont pas réservés aux langages fonctionnels.
En C# sur .NET Framework, une classe abstraite et des classes sealed suffisent amplement à un usage pratique.
En C# sur le .NET actuel, les records et le pattern matching permettent d’écrire de manière plus concise.
En F#, les unions discriminées sont directement une fonctionnalité du langage.
Avec des bibliothèques, même C# peut manipuler facilement OneOf ou Result.
Ce qui compte, ce n’est pas la syntaxe, mais la démarche de conception.
Il s’agit de revisiter ce que l’on exprimait avec bool, null, enum + propriétés nullable ou string ErrorCode, et de se poser ces questions :
De quels cas cette valeur est-elle exactement l'un ?
De quelles données chaque cas a-t-il besoin ?
Quelles données ne doivent exister que dans ce cas précis et nulle part ailleurs ?
Que veut-on absolument faire traiter par l'appelant ?
En construisant des types qui répondent à ces questions, les états invalides se raréfient, les branchements gagnent en clarté, et le vocabulaire métier reste dans le code.
Dans les systèmes existants, nous recommandons de commencer par les valeurs de retour.
Repérer les endroits où TryXxx, null, ErrorCode et le branchement métier par exceptions se sont accumulés, et essayer de les remplacer par un type de résultat dédié.
Cela seul change déjà considérablement la lisibilité et la sûreté du code.
Références
- L’ensemble du code d’exemple de cet article (bibliothèque, démonstrations, tests unitaires) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-algebraic-data-types
- Discriminated Unions - F# | Microsoft Learn
- Pattern matching overview - C# | Microsoft Learn
- switch expression - C# reference | Microsoft Learn
- Records - C# reference | Microsoft Learn
- .NET Standard - .NET | Microsoft Learn
- Explore union types in C# 15 - .NET Blog
- Unions - C# feature specifications | Microsoft Learn
- OneOf - NuGet
- Thinktecture.Runtime.Extensions - NuGet
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Jusqu'à quand les applications VB6 continueront-elles de fonctionner ? — état du support du runtime et démarche concrète vers une migration .NET
Jusqu'à quand les applications VB6 continueront-elles de fonctionner ? Cet article clarifie l'asymétrie entre la politique de support du ...
Date, heure et fuseaux horaires dans les applications métier — des pièges de DateTime au principe de stockage en UTC et à la conception des tests
Un déplacement de serveur fait dériver les horaires de neuf heures : cet article remonte à la source des incidents de date/heure, la prop...
Le support du haut DPI dans WPF — pourquoi l'affichage reste flou et baveux malgré une application « censée résister au DPI », et comment y remédier
WPF met en page en DIP (1/96 de pouce) et est System DPI Aware dès le départ, mais déplacer une fenêtre vers un moniteur au DPI différent...
Le problème d'EXCEL.EXE qui reste actif lors de la manipulation d'Excel en C# — schémas de libération des références COM et décision de remplacement
Analyse du problème du processus EXCEL.EXE qui reste actif après une automatisation COM d'Excel depuis C#, à partir du comptage de référe...
La prise en charge du haut DPI dans WinForms — pourquoi l'interface devient floue ou se casse sur les moniteurs 4K, et solutions concrètes
Cet article explique pourquoi les applications WinForms deviennent floues ou voient leur mise en page se casser sur les moniteurs 4K, à p...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Qu'est-ce qu'un type de données algébrique (ADT) ?
- Un type de données algébrique combine les types produit (un type qui porte à la fois A et B) et les types somme (un type qui est soit A, soit B). En pratique, l'idée est d'exprimer avec des types — et non avec des commentaires ou des conventions de nommage — le fait que « cette valeur a un ensemble de formes possibles déterminé à l'avance ». Ce sont surtout les types somme qui servent au quotidien : par exemple, on exprime un résultat de paiement comme « Succeeded(receiptNo), ou Rejected(reason), ou NetworkFailure(message) — une seule de ces trois possibilités » avec un type, ce qui rend impossible la construction d'états invalides.
- Comment implémenter une union discriminée (type somme) en C# ?
- Pour les systèmes existants, y compris .NET Framework, le schéma le plus facile à adopter est une classe abstraite de base avec un constructeur private, des classes sealed imbriquées et une méthode Match. Comme seuls les types imbriqués peuvent hériter de la classe de base, on obtient un ensemble de cas fermé. À partir de .NET 5, une hiérarchie d'abstract record et de sealed record permet une écriture plus concise. Pour des valeurs de retour locales, une bibliothèque comme OneOf est aussi une option, et F# permet d'utiliser nativement les unions discriminées comme fonctionnalité du langage.
- Quand faut-il préférer un type de données algébrique à un enum ou un bool ?
- Un enum suffit lorsqu'il suffit de connaître le cas ; un bool suffit lorsqu'il s'agit vraiment d'un choix binaire ne nécessitant aucune information supplémentaire. En revanche, quand chaque cas porte des données différentes (par exemple receiptNo en cas de succès, shortage en cas de solde insuffisant), un ADT est mieux adapté. Le signal à surveiller : un enum accompagné d'un nombre croissant de propriétés nullable, ou des comparaisons de codes d'erreur sous forme de string qui se multiplient — c'est le moment de passer à un ADT. Mais si la conception permet à des cas externes de s'ajouter, par exemple via des plugins, une interface convient mieux qu'un ADT fermé.
- Pour un échec métier, faut-il utiliser une exception ou un type Result ?
- En pratique, il est efficace de répartir les rôles ainsi : les échecs attendus que l'appelant doit traiter comme un branchement normal (rupture de stock, e-mail en double, mot de passe erroné, etc.) sont renvoyés via un Result/ADT, tandis que les anomalies imprévues dont le traitement normal ne peut pas se remettre (coupure de la connexion à la base de données, fichier de configuration corrompu, etc.) restent des exceptions. Si l'on transforme en exceptions même les branchements métier qui se produisent couramment, le try-catch finit par remplacer le branchement métier et la logique devient difficile à suivre. Cette seule répartition des rôles améliore déjà nettement la lisibilité de la couche service applicatif et de la couche API.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.
Liens publics