CQRS — Command Query Responsibility Segregation — est l'un de ces patterns que j'ai mis trop longtemps à adopter vraiment. Pas parce que le concept est compliqué, mais parce que la majorité des articles que j'ai lus l'abordaient soit de façon trop abstraite, soit en sautant directement vers l'event sourcing et les bases de données séparées. Résultat : j'avais l'impression que c'était réservé aux systèmes à très grande échelle. C'est faux. Voici ce que j'aurais voulu lire il y a dix ans, avec du code C# concret.
Pourquoi CQRS change vraiment la donne
Dans une architecture traditionnelle, le même service gère à la fois la lecture et l'écriture. Le résultat est souvent un OrderService avec 15 méthodes qui mélangent GetById, GetByCustomer, CreateOrder, CancelOrder, UpdateStatus… Le fichier grossit, les responsabilités se mélangent, et personne ne sait vraiment quelle méthode a des effets de bord.
CQRS résout ça avec une règle simple : les opérations qui modifient l'état (commandes) sont strictement séparées des opérations qui lisent l'état (queries). Une commande ne retourne pas de données, une query ne modifie pas l'état. Cette contrainte, une fois intériorisée, clarifie radicalement l'architecture.
"Le vrai bénéfice de CQRS n'est pas la performance ni la scalabilité — c'est la lisibilité. Quand je vois une classe qui implémenteICommandHandler, je sais qu'elle a des effets de bord. Quand je voisIQueryHandler, je sais qu'elle est pure en lecture. Cette sémantique vaut de l'or en revue de code."
Côté testabilité, la séparation est tout aussi précieuse. Les commandes testent les invariants du domaine et les effets de bord. Les queries testent la projection des données. Les deux s'écrivent sans se marcher dessus.
Setup MediatR — le minimum viable en .NET 9
MediatR est le médiateur qui dispatche vos commandes et queries vers leurs handlers respectifs. L'installation est triviale :
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Dans Program.cs ou votre module d'enregistrement :
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly));
C'est tout. MediatR scanne l'assembly et enregistre automatiquement tous les handlers qu'il trouve. Pas de configuration manuelle, pas de registre à maintenir.
Commande complète : de l'IRequest au test unitaire
Prenons un use case concret : la création d'une commande client dans un système e-commerce. Voici l'implémentation complète, couche par couche.
Le contrat : IRequest et sa réponse
// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public sealed record CreateOrderCommand(
string CustomerId,
IReadOnlyList<OrderLineDto> Lines,
string ShippingAddressId
) : IRequest<CreateOrderResult>;
public sealed record CreateOrderResult(
string OrderId,
decimal TotalAmount,
DateTime CreatedAt
);
public sealed record OrderLineDto(
string ProductId,
int Quantity,
decimal UnitPrice
);
Le record est immuable par nature — parfait pour une commande. Aucun setter, aucune mutation après construction. J'utilise IRequest<TResult> et non IRequest car je veux récupérer l'ID de la commande créée — c'est la seule information que je m'autorise à retourner depuis une commande.
Le handler
// Application/Orders/Commands/CreateOrder/CreateOrderCommandHandler.cs
public sealed class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
private readonly TimeProvider _timeProvider;
public CreateOrderCommandHandler(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
TimeProvider timeProvider)
{
_orderRepository = orderRepository;
_customerRepository = customerRepository;
_timeProvider = timeProvider;
}
public async Task<CreateOrderResult> Handle(
CreateOrderCommand command,
CancellationToken cancellationToken)
{
var customer = await _customerRepository
.GetByIdAsync(command.CustomerId, cancellationToken)
?? throw new CustomerNotFoundException(command.CustomerId);
var lines = command.Lines
.Select(l => OrderLine.Create(l.ProductId, l.Quantity, l.UnitPrice))
.ToList();
var order = Order.Create(
customer.Id,
lines,
command.ShippingAddressId,
_timeProvider.GetUtcNow());
await _orderRepository.AddAsync(order, cancellationToken);
return new CreateOrderResult(
order.Id.Value,
order.TotalAmount,
order.CreatedAt);
}
}
Le handler ne sait pas d'où vient la commande — HTTP, message queue, job planifié, peu importe. Cette indépendance est fondamentale : je peux réutiliser ce handler depuis n'importe quel point d'entrée de l'application sans duplication.
Le validator FluentValidation
// Application/Orders/Commands/CreateOrder/CreateOrderCommandValidator.cs
public sealed class CreateOrderCommandValidator
: AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.ShippingAddressId)
.NotEmpty();
RuleFor(x => x.Lines)
.NotEmpty()
.WithMessage("Une commande doit contenir au moins une ligne.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThan(0);
});
}
}
Le test unitaire du handler
// Tests/Application/Orders/Commands/CreateOrderCommandHandlerTests.cs
public sealed class CreateOrderCommandHandlerTests
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
private readonly FakeTimeProvider _timeProvider;
private readonly CreateOrderCommandHandler _sut;
public CreateOrderCommandHandlerTests()
{
_orderRepository = Substitute.For<IOrderRepository>();
_customerRepository = Substitute.For<ICustomerRepository>();
_timeProvider = new FakeTimeProvider(
new DateTimeOffset(2026, 3, 9, 10, 0, 0, TimeSpan.Zero));
_sut = new CreateOrderCommandHandler(
_orderRepository, _customerRepository, _timeProvider);
}
[Fact]
public async Task Handle_WhenValidCommand_ReturnsCreatedOrder()
{
// Arrange
var customer = Customer.Create("CUST-001", "Dupont", "jean@example.com");
_customerRepository
.GetByIdAsync("CUST-001", Arg.Any<CancellationToken>())
.Returns(customer);
var command = new CreateOrderCommand(
CustomerId: "CUST-001",
Lines: [new OrderLineDto("PROD-A", 2, 49.99m)],
ShippingAddressId: "ADDR-001");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.TotalAmount.Should().Be(99.98m);
await _orderRepository
.Received(1)
.AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenCustomerNotFound_ThrowsCustomerNotFoundException()
{
// Arrange
_customerRepository
.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Customer?)null);
var command = new CreateOrderCommand("UNKNOWN", [], "ADDR-001");
// Act
var act = () => _sut.Handle(command, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<CustomerNotFoundException>();
}
}
Pipeline Behaviors : la couche transversale
Les pipeline behaviors sont l'une des fonctionnalités les plus puissantes de MediatR. Ils s'insèrent dans le pipeline d'exécution de chaque requête — exactement comme les middlewares ASP.NET Core, mais pour votre couche application. J'en utilise systématiquement trois.
Behavior de validation
// Application/Common/Behaviors/ValidationBehavior.cs
public sealed class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if(!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if(failures.Count != 0)
{
throw new ValidationException(failures);
}
return await next();
}
}
Behavior de logging et performance
// Application/Common/Behaviors/LoggingBehavior.cs
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
=> _logger = logger;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
_logger.LogInformation("Handling {RequestName}", requestName);
var sw = Stopwatch.StartNew();
var response = await next();
sw.Stop();
if(sw.ElapsedMilliseconds > 500)
{
_logger.LogWarning(
"Slow request detected: {RequestName} took {Elapsed}ms",
requestName, sw.ElapsedMilliseconds);
}
_logger.LogInformation(
"Handled {RequestName} in {Elapsed}ms",
requestName, sw.ElapsedMilliseconds);
return response;
}
}
L'enregistrement des behaviors se fait dans le DI, dans l'ordre d'exécution souhaité :
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
// Enregistrer les validators FluentValidation
builder.Services.AddValidatorsFromAssembly(
typeof(ApplicationAssemblyMarker).Assembly);
Queries : la séparation des lectures
Pour les queries, je n'hésite pas à utiliser des projections directes via EF Core plutôt que de passer par les entités du domaine. Une query qui retourne un DTO de liste n'a pas besoin de charger les agrégats complets avec tous leurs invariants.
// Application/Orders/Queries/GetOrdersByCustomer/GetOrdersByCustomerQuery.cs
public sealed record GetOrdersByCustomerQuery(
string CustomerId,
int PageNumber = 1,
int PageSize = 20
) : IRequest<PagedResult<OrderSummaryDto>>;
// Handler — projection directe, pas d'agrégat
public sealed class GetOrdersByCustomerQueryHandler
: IRequestHandler<GetOrdersByCustomerQuery, PagedResult<OrderSummaryDto>>
{
private readonly IReadDbContext _readDbContext;
public GetOrdersByCustomerQueryHandler(IReadDbContext readDbContext)
=> _readDbContext = readDbContext;
public async Task<PagedResult<OrderSummaryDto>> Handle(
GetOrdersByCustomerQuery query,
CancellationToken cancellationToken)
{
var baseQuery = _readDbContext.Orders
.AsNoTracking()
.Where(o => o.CustomerId == query.CustomerId)
.OrderByDescending(o => o.CreatedAt);
var total = await baseQuery.CountAsync(cancellationToken);
var items = await baseQuery
.Skip((query.PageNumber - 1) * query.PageSize)
.Take(query.PageSize)
.Select(o => new OrderSummaryDto(
o.Id,
o.Status,
o.TotalAmount,
o.CreatedAt,
o.Lines.Count))
.ToListAsync(cancellationToken);
return new PagedResult<OrderSummaryDto>(items, total, query.PageNumber, query.PageSize);
}
}
Notez l'AsNoTracking() — absolument indispensable sur toutes les queries. Le tracking EF Core n'a aucune valeur ici et coûte de la mémoire et du CPU inutilement.
Organisation des fichiers et conventions
Je groupe tout par feature, pas par type. Chaque use case a son propre dossier qui contient tout ce dont il a besoin :
Application/
Orders/
Commands/
CreateOrder/
CreateOrderCommand.cs
CreateOrderCommandHandler.cs
CreateOrderCommandValidator.cs
Queries/
GetOrdersByCustomer/
GetOrdersByCustomerQuery.cs
GetOrdersByCustomerQueryHandler.cs
Common/
Behaviors/
LoggingBehavior.cs
ValidationBehavior.cs
Exceptions/
ValidationException.cs
CustomerNotFoundException.cs
Cette organisation "vertical slice" à l'intérieur de la couche application est la clé de la maintenabilité à long terme. Quand un développeur doit modifier le use case CreateOrder, il sait exactement où aller. Il n'y a pas de chasse aux trésors entre des dossiers Services/, Validators/, Models/ éparpillés.
"La règle que j'applique sur toutes mes missions : si je dois ouvrir plus de trois dossiers pour comprendre un use case, l'organisation est mauvaise. CQRS avec vertical slices résout ça structurellement."
Ce que CQRS ne résout pas
Soyons honnêtes : CQRS ajoute de la surface de code. Chaque use case crée au moins 3 fichiers (commande/query, handler, validator). Sur un domaine de 50 use cases, ça représente 150 fichiers à gérer. C'est le prix de la séparation des responsabilités.
CQRS ne résout pas non plus les problèmes de modélisation du domaine. Si vos agrégats sont mal délimités, si vos invariants ne sont pas respectés, si votre modèle ne reflète pas le domaine métier — CQRS ne changera rien à ça. C'est un pattern d'organisation, pas une solution à la complexité métier.
Enfin, pour les petits projets CRUD sans règles métier, c'est souvent de l'over-engineering. J'ai appris à résister à la tentation d'appliquer le même pattern partout. La question à se poser avant d'introduire CQRS : "Est-ce que les commandes et les queries de ce contexte ont des besoins structurellement différents ?" Si la réponse est non, simplifiez.
Vous structurez un projet .NET avec CQRS ?
J'interviens en mission pour poser les fondations architecturales de vos projets .NET
ou auditer une architecture existante.