Per anni, aprire un nuovo progetto .NET significava quasi automaticamente aggiungere una dipendenza: dotnet add package MediatR. La libreria di Jimmy Bogard è diventata così sinonimo di CQRS nell’ecosistema .NET che molti sviluppatori faticavano a distinguere il pattern dall’implementazione.
Poi MediatR è passato a una licenza commerciale. Ogni team che aveva costruito la propria architettura intorno ad essa si è trovato a fare la stessa domanda: abbiamo davvero bisogno di questa libreria?
Questo articolo non è una critica a MediatR né al suo autore — la libreria ha plasmato il modo in cui una generazione di sviluppatori .NET pensa agli handler, alle pipeline e alla separazione delle responsabilità. Il cambio di licenza è semplicemente un’opportunità per guardare cosa c’è sotto, e rendersi conto di quanto poco richieda realmente una libreria esterna.
Cos’è davvero CQRS
Command Query Responsibility Segregation ha due componenti fondamentali:
- Commands: modificano lo stato. Hanno effetti collaterali. Possono restituire un risultato, ma il loro scopo primario è modificare il sistema.
- Queries: leggono lo stato. Non hanno effetti collaterali. Restituiscono dati e nient’altro.
È tutto qui. Il dispatcher, gli handler, i pipeline behavior sono dettagli implementativi. Nessuno di essi richiede una libreria. Il DI container di .NET ha già tutto il necessario per implementare CQRS in modo pulito e testabile.
L’astrazione minima
Si parte con due famiglie di interfacce: una per i command, una per le query.
// Interfacce marker — esistono solo per il sistema di tipi
public interface ICommand { }
public interface ICommand<TResult> { }
public interface IQuery<TResult> { }
// Handler
public interface ICommandHandler<TCommand>
where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken ct = default);
}
public interface ICommandHandler<TCommand, TResult>
where TCommand : ICommand<TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken ct = default);
}
public interface IQueryHandler<TQuery, TResult>
where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken ct = default);
}
Cinque interfacce. Zero dipendenze esterne. Il compilatore verifica la relazione tra command, query e i relativi handler. Le interfacce marker ICommand e IQuery non sono decorazione: sono il contratto che rende sicuro il tipo nel dispatcher e nelle scansioni dell’assembly.
Un command e un handler concreti
public record CreateOrder(string CustomerEmail, List<OrderLine> Lines)
: ICommand<OrderId>;
public class CreateOrderHandler : ICommandHandler<CreateOrder, OrderId>
{
private readonly IOrderRepository _orders;
private readonly IEventBus _events;
public CreateOrderHandler(IOrderRepository orders, IEventBus events)
{
_orders = orders;
_events = events;
}
public async Task<OrderId> HandleAsync(CreateOrder command, CancellationToken ct = default)
{
var order = Order.Create(command.CustomerEmail, command.Lines);
await _orders.SaveAsync(order, ct);
await _events.PublishAsync(new OrderCreated(order.Id), ct);
return order.Id;
}
}
E una query:
public record GetOrderById(Guid OrderId) : IQuery<OrderDto?>;
public class GetOrderByIdHandler : IQueryHandler<GetOrderById, OrderDto?>
{
private readonly IOrderReadModel _reads;
public GetOrderByIdHandler(IOrderReadModel reads) => _reads = reads;
public Task<OrderDto?> HandleAsync(GetOrderById query, CancellationToken ct = default)
=> _reads.GetByIdAsync(query.OrderId, ct);
}
Il Dispatcher
Il dispatcher risolve l’handler corretto per un dato command o query e lo invoca. Esiste affinché i chiamanti non debbano iniettare ogni handler individualmente: iniettano un unico dispatcher e inviano messaggi attraverso di esso.
public interface ICommandDispatcher
{
Task SendAsync(ICommand command, CancellationToken ct = default);
Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default);
}
public class CommandDispatcher : ICommandDispatcher
{
private readonly IServiceProvider _provider;
public CommandDispatcher(IServiceProvider provider) => _provider = provider;
public Task SendAsync(ICommand command, CancellationToken ct = default)
{
var handlerType = typeof(ICommandHandler<>).MakeGenericType(command.GetType());
dynamic handler = _provider.GetRequiredService(handlerType);
return handler.HandleAsync((dynamic)command, ct);
}
public Task<TResult> SendAsync<TResult>(ICommand<TResult> command, CancellationToken ct = default)
{
var handlerType = typeof(ICommandHandler<,>)
.MakeGenericType(command.GetType(), typeof(TResult));
dynamic handler = _provider.GetRequiredService(handlerType);
return handler.HandleAsync((dynamic)command, ct);
}
}
Registrazione nel DI container
La registrazione automatica di tutti gli handler si fa con Scrutor (o manualmente per progetti piccoli):
services.AddScoped<ICommandDispatcher, CommandDispatcher>();
services.AddScoped<IQueryDispatcher, QueryDispatcher>();
// Con Scrutor: scansione automatica degli handler
services.Scan(scan => scan
.FromAssemblyOf<CreateOrderHandler>()
.AddClasses(c => c.AssignableTo(typeof(ICommandHandler<>)))
.AsImplementedInterfaces()
.WithScopedLifetime()
.AddClasses(c => c.AssignableTo(typeof(ICommandHandler<,>)))
.AsImplementedInterfaces()
.WithScopedLifetime()
.AddClasses(c => c.AssignableTo(typeof(IQueryHandler<,>)))
.AsImplementedInterfaces()
.WithScopedLifetime());
Pipeline behavior senza magia
Uno degli aspetti più apprezzati di MediatR è la pipeline dei behavior: logging, validazione, transazioni. Si replicano con il pattern Decorator, che il DI container di .NET supporta nativamente.
public class LoggingCommandHandlerDecorator<TCommand, TResult>
: ICommandHandler<TCommand, TResult>
where TCommand : ICommand<TResult>
{
private readonly ICommandHandler<TCommand, TResult> _inner;
private readonly ILogger _logger;
public LoggingCommandHandlerDecorator(
ICommandHandler<TCommand, TResult> inner,
ILogger<LoggingCommandHandlerDecorator<TCommand, TResult>> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<TResult> HandleAsync(TCommand command, CancellationToken ct = default)
{
_logger.LogInformation("Executing {CommandType}", typeof(TCommand).Name);
var result = await _inner.HandleAsync(command, ct);
_logger.LogInformation("Completed {CommandType}", typeof(TCommand).Name);
return result;
}
}
Uso diretto nelle Minimal API
In contesti semplici o con Minimal API, il dispatcher può essere saltato del tutto: si inietta l’handler direttamente nell’endpoint.
app.MapPost("/orders", async (
CreateOrder command,
ICommandHandler<CreateOrder, OrderId> handler,
CancellationToken ct) =>
{
var id = await handler.HandleAsync(command, ct);
return Results.Created($"/orders/{id}", id);
});
Questa scelta rende esplicita la dipendenza e semplifica i test dell’endpoint.
Errori comuni da evitare
CQRS non significa due database. Il pattern separa le responsabilità concettuali, non impone necessariamente read model separati o database distinti. Partire con un unico database va benissimo.
I command non contengono logica di business. Sono semplici DTO. La logica vive negli handler e nel domain model.
Gli handler non chiamano altri handler. Se un handler ha bisogno dei servizi di un altro, si estrae la logica comune in un servizio di dominio condiviso.
I command non sono DTO di input dell’API. Separare i modelli di input HTTP dai command protegge il core applicativo dai cambiamenti del contratto HTTP.
Quando MediatR ha ancora senso
Se il progetto usa già MediatR con licenza valida, non c’è fretta di migrare. Se si ha un’applicazione molto grande con decine di behavior cross-cutting complessi, MediatR offre un ecosistema di plugin testato. Per nuovi progetti o migrazioni obbligate, l’implementazione hand-rolled è spesso più semplice da capire e mantenere.
Conclusione
CQRS è un pattern di separazione concettuale, non una libreria. Il DI container di .NET fornisce tutto il necessario per implementarlo in modo pulito, testabile e privo di dipendenze esterne non necessarie. Il cambio di licenza di MediatR è stata l’occasione per molti team di riscoprire quanto poco codice ci voglia per ottenere gli stessi benefici architetturali.
Fonte: CQRS Without MediatR: Hand-Rolled Command and Query Handlers in .NET — Adrian Bailador