Stéphane De Todaro — tech lead

@super-dev.app · Lead-technique
Actif depuis 2017

Lead technique et architecte full-stack, freelance depuis 2019. Je conçois, industrialise et exploite des plateformes métier sur Azure, et je publie des moteurs logiciels open source.

Retour aux articles
.NET
.NET

CQRS et vertical slices sans usine à gaz

Article 3 sur 5 — Le .NET moderne
APIs minimales, CQRS, gRPC, source generators : du .NET 8 net et testable, sans usine à gaz.

Beaucoup d'équipes rangent CQRS dans le même tiroir que l'event sourcing, les bus de messages et les bases de données dupliquées. L'idée de départ est pourtant modeste : séparer le chemin des lectures de celui des écritures. On l'applique sans infrastructure supplémentaire, en organisant le code par vertical slices , une fonctionnalité par dossier.

Découper par fonctionnalité, pas par couche

L'architecture en couches éclate une fonctionnalité dans cinq dossiers : Controllers , Services , Repositories , DTOs , Validators . Pour suivre « créer une commande » de bout en bout, on saute de fichier en fichier, et chaque dossier finit par mélanger des morceaux de dizaines de fonctionnalités sans rapport.

La vertical slice inverse le rangement : un dossier par cas d'usage, tout ce qui le concerne au même endroit.

Bash
1Features/
2 Orders/
3 CreateOrder.cs # command + handler + validator
4 GetOrderById.cs # query + handler
5 ListOrders.cs

Un slice contient sa requête, son handler, le validateur qui va avec et le DTO qu'il renvoie. Il se lit de haut en bas et se supprime sans effet de bord : rien d'autre ne dépend de lui. Deux slices ne partagent que le domaine , jamais un « service » fourre-tout dans lequel toute l'application vient piocher. Jimmy Bogard, qui a popularisé l'approche sous le nom de vertical slice architecture , en donne la contrainte tenante : ce qui change ensemble vit ensemble.

Commande et requête, deux intentions distinctes

Une commande modifie l'état et ne renvoie idéalement qu'un identifiant, ou rien. Une requête ne lit que ce dont la vue a besoin et ne touche à rien. Les modéliser comme deux types séparés rend l'intention lisible dès la signature.

La distinction n'est pas neuve. Elle prolonge le principe de Command-Query Separation de Bertrand Meyer, où une méthode change l'état ou renvoie une valeur, jamais les deux. Greg Young en a fait un sigle, CQRS, en poussant l'idée à l'échelle d'un modèle entier plutôt que d'une méthode.

C#
1public sealed record CreateOrder(Guid CustomerId, IReadOnlyList<LineItem> Items)
2 : IRequest<Guid>;
3
4public sealed class CreateOrderHandler(AppDbContext db)
5 : IRequestHandler<CreateOrder, Guid>
6{
7 public async Task<Guid> Handle(CreateOrder command, CancellationToken ct)
8 {
9 var order = Order.Create(command.CustomerId, command.Items);
10 db.Orders.Add(order);
11 await db.SaveChangesAsync(ct);
12
13 return order.Id;
14 }
15}

Le handler reste mince : il orchestre, il ne raisonne pas. La logique métier vit dans Order.Create , où l'agrégat protège ses invariants. Si elle migre dans le handler, on a juste renommé le « service » qu'on voulait fuir.

Un handler aussi plat se teste sans cérémonie : il reçoit ses dépendances en paramètres, on l'instancie avec un AppDbContext sur SQLite ou le provider in-memory, on appelle Handle , on inspecte le retour. Pas de serveur HTTP à monter, pas de pipeline à simuler.

Deux modèles de données pour deux besoins

C'est côté lecture que la séparation paie, même avec une seule base. Le côté écriture passe par l'agrégat parce qu'il doit valider des règles avant de muter l'état. Le côté lecture n'a aucune raison de reconstruire cet agrégat : il projette directement la table vers le DTO attendu par l'appelant.

C#
1public sealed record GetOrderSummary(Guid OrderId) : IRequest<OrderSummary?>;
2
3public sealed record OrderSummary(Guid Id, string Customer, decimal Total, int LineCount);
4
5public sealed class GetOrderSummaryHandler(AppDbContext db)
6 : IRequestHandler<GetOrderSummary, OrderSummary?>
7{
8 public Task<OrderSummary?> Handle(GetOrderSummary query, CancellationToken ct) =>
9 db.Orders
10 .AsNoTracking()
11 .Where(o => o.Id == query.OrderId)
12 .Select(o => new OrderSummary(
13 o.Id,
14 o.Customer.Name,
15 o.Lines.Sum(l => l.Quantity * l.UnitPrice),
16 o.Lines.Count))
17 .SingleOrDefaultAsync(ct);
18}

AsNoTracking coupe le change tracker, inutile pour une lecture, et le Select laisse EF Core générer un SELECT qui ne ramène que les colonnes projetées. La requête n'est plus prisonnière de la forme du modèle d'écriture : elle assemble exactement la vue voulue, sommes et jointures comprises.

Le jour où les lectures deviennent le goulot d'étranglement, ce même slice devient le point d'accroche pour brancher du Dapper, du SQL brut ou une table dénormalisée, sans toucher au côté écriture. Rien n'oblige à le faire tant qu'une métrique ne l'exige pas.

Le médiateur, un détail d'implémentation

CQRS arrive souvent collé à MediatR . Le médiateur découple l'endpoint du handler et offre un point d'accroche pour les pipeline behaviors . Un endpoint se contente alors d'envoyer le message : await sender.Send(command) renvoie l'identifiant, que l'on emballe dans un TypedResults.Created .

Pratique, mais à part : rien dans CQRS n'impose un médiateur. On peut injecter le handler directement dans l'endpoint et l'appeler à la main. MediatR est d'ailleurs passé sous licence commerciale, ce qui rend l'option maison plus sérieuse qu'avant : un dispatcher qui résout IRequestHandler<,> dans le conteneur et appelle Handle tient en une quinzaine de lignes. Sur une petite application, cette indirection en moins vaut souvent le geste.

L'endpoint lui-même peut vivre dans le fichier du slice, exposé par une méthode d'extension MapCreateOrder que le Program.cs se contente d'appeler comme un MapGroup . La route, la commande et le handler tiennent alors dans le même fichier, et rien de la fonctionnalité ne traîne ailleurs.

Où placer la validation et le transverse

Validation, journalisation, ouverture de transaction : ces préoccupations reviennent dans chaque slice. Les recopier dans chaque handler est la meilleure façon d'en oublier une. Un pipeline behavior les factorise en enveloppant tous les handlers d'un coup.

C#
1public sealed class ValidationBehavior<TRequest, TResponse>(
2 IEnumerable<IValidator<TRequest>> validators)
3 : IPipelineBehavior<TRequest, TResponse>
4 where TRequest : notnull
5{
6 public async Task<TResponse> Handle(
7 TRequest request,
8 RequestHandlerDelegate<TResponse> next,
9 CancellationToken ct)
10 {
11 var context = new ValidationContext<TRequest>(request);
12 var failures = validators
13 .Select(v => v.Validate(context))
14 .SelectMany(result => result.Errors)
15 .ToList();
16
17 if (failures.Count != 0)
18 {
19 throw new ValidationException(failures);
20 }
21
22 return await next();
23 }
24}

Chaque slice déclare son validateur FluentValidation , le behavior le récupère par injection et le déclenche avant le handler. Enregistré une fois via AddOpenBehavior(typeof(ValidationBehavior<,>)) , il couvre commandes et requêtes sans code répété.

Le behavior a un coût : c'est du flux de contrôle invisible. Il en faut peu, chacun clairement borné, sinon on recrée la magie qu'on reprochait au médiateur.

Ne pas sur-concevoir

La question à poser devant chaque slice reste la même : en ai-je vraiment besoin ? Bases séparées, projections asynchrones, event sourcing répondent à des problèmes précis : lectures massivement supérieures aux écritures, audit immuable, modèle de lecture très éloigné du modèle d'écriture. En l'absence de ce problème, ils n'ajoutent que de la latence et des bugs de cohérence à retardement.

Le CQRS utile, dans l'immense majorité des cas : des commandes et des requêtes distinctes, un seul DbContext , des slices qu'on lit sans grep. Le reste attend une raison chiffrée.

CQRS commence par une discipline de nommage avant d'être une architecture. Séparez les intentions, gardez les handlers minces, laissez les lectures court-circuiter le domaine, et n'ajoutez un bus de messages que le jour où une mesure vous y oblige.
Stéphane De Todaro — super-dev.app
// Continuer dans .NET
Externaliser le SQL dans les migrations EF Core : procédures et vues en fichiers versionnés
14 août 2026 • 8 min
Orchestrer sans base de données : un découpage ProcessAPI / SystemAPI en .NET
14 août 2026 • 10 min
{{ }}
Interpréter plutôt que compiler : un moteur de templates pour .NET
30 juil. 2026 • 6 min