Passa al contenuto principale

Command in C#

Il Command in C# si esprime tipicamente in due modi: con il pattern IUseCase nativo della solution o con la libreria MediatR. Entrambi reificano l'operazione in un oggetto e separano il chiamante dall'esecutore.


1. Command con IUseCase

Idea

Il pattern IUseCase, descritto in struttura-soluzione/04-usecases, è già un'implementazione di Command: ogni caso d'uso è una classe che riceve l'input come parametro, orchestra il dominio e restituisce un Result<T>.

public record RegisterCustomer(string Name, string VatId, string Email);

public class RegisterCustomerUseCase : IUseCase
{
private readonly AppDbContext _db;
private readonly INotificationSender _notifier;
private readonly TimeProvider _clock;

public RegisterCustomerUseCase(
AppDbContext db, INotificationSender notifier, TimeProvider clock)
{
_db = db;
_notifier = notifier;
_clock = clock;
}

public async Task<Result<Guid>> HandleAsync(RegisterCustomer command, CancellationToken ct)
{
var existing = await _db.Customers
.AnyAsync(c => c.VatId == command.VatId, ct);
if (existing)
return Result.Failure<Guid>(CustomerErrors.AlreadyExists);

var customer = new Customer(Guid.NewGuid(), command.Name, command.VatId, command.Email, _clock.GetUtcNow());
_db.Customers.Add(customer);
await _db.SaveChangesAsync(ct);

await _notifier.SendWelcomeAsync(customer.Email, ct);
return Result.Success(customer.Id);
}
}

Chiamata da un controller

[HttpPost]
public async Task<IActionResult> Register(
[FromBody] RegisterCustomer command,
[FromServices] RegisterCustomerUseCase useCase,
CancellationToken ct)
{
var result = await useCase.HandleAsync(command, ct);
return result.ToActionResult();
}

Il controller non orchestra: riceve il command (dall'HTTP), invoca lo use case, mappa il risultato. Questa forma è descritta come default nella sezione su UseCases.


2. Command con MediatR

Idea

MediatR introduce un dispatcher esplicito (ISender) che individua l'handler giusto per il command e gli applica una pipeline di behavior comuni a tutti i command. Il chiamante perde la dipendenza diretta dallo use case.

public record RegisterCustomer(string Name, string VatId, string Email) : IRequest<Result<Guid>>;

public class RegisterCustomerHandler : IRequestHandler<RegisterCustomer, Result<Guid>>
{
private readonly AppDbContext _db;
private readonly INotificationSender _notifier;
private readonly TimeProvider _clock;

public RegisterCustomerHandler(AppDbContext db, INotificationSender notifier, TimeProvider clock)
{
_db = db;
_notifier = notifier;
_clock = clock;
}

public async Task<Result<Guid>> Handle(RegisterCustomer command, CancellationToken ct)
{
// identica all'esempio precedente
}
}

Chiamata da un controller

[HttpPost]
public async Task<IActionResult> Register(
[FromBody] RegisterCustomer command,
[FromServices] ISender sender,
CancellationToken ct)
{
var result = await sender.Send(command, ct);
return result.ToActionResult();
}

Il controller ora dipende solo da ISender. Aggiungere un nuovo command non richiede di iniettare uno use case in più.

Pipeline behavior

I behavior MediatR sono decorator applicati a tutti i command. Vedi Chain of Responsibility, MediatR pipeline behavior per implementazione e registrazione di logging e validation behavior.


3. Quale dei due

Coerentemente con il principio di una sola forma consigliata, la scelta va fatta a livello di solution e non mescolata.

CriterioIUseCaseMediatR
Dipendenze esplicite nel controllerSì: ogni controller inietta gli use case che usaNo: si inietta solo ISender
Navigabilità nell'IDE«Vai alla definizione» porta direttamente all'handlerRichiede di cercare l'handler dell'IRequest
Dipendenza esternaNessunaPacchetto MediatR
Comportamenti trasversaliDecorator manuali o filter MVCPipeline behavior, una sola registrazione
Verbosità per command «puntuali»BassaBassa
Verbosità con molti behavior trasversaliAlta (decorator manuali)Bassa

Raccomandazione:

  • Per solution piccole o medie con pochi comportamenti trasversali, IUseCase è più diretto, più navigabile e non aggiunge dipendenze.
  • Per solution dove serve applicare in modo uniforme logging, validazione, transazione, audit a ogni command, MediatR riduce il boilerplate.

Non mescolare le due forme nello stesso progetto.


4. Command e CQRS

Il pattern Command è la base lato scrittura di CQRS: le scritture passano per command (con esito esplicito), le letture per query (senza side effect). In questa solution la separazione è leggera:

  • Command = IUseCase che modifica stato e chiama SaveChangesAsync.
  • Query = IUseCase (o handler MediatR) che proietta dati con IQueryable.Select e non tocca lo stato.

Non serve un'infrastruttura CQRS separata: la separazione concettuale è sufficiente, l'infrastruttura DI è la stessa.


5. Anti-pattern

Command anemico

public record DoStuff(string Data);

Un nome generico non comunica nulla. Il command è parte del vocabolario del dominio: RegisterCustomer, ShipOrder, RefundPayment. Se non si riesce a dargli un nome di dominio, probabilmente è una query travestita o un'operazione non ancora ben definita.

Logica nel command

Il command è un dato, non un servizio. Non ha metodi che eseguono lavoro, non ha dipendenze iniettate. È un DTO immutabile (idealmente un record).

Esiti via eccezione

Un command che fallisce per regola di business deve restituire Result.Failure. Le eccezioni sono per errori imprevisti, non per esiti previsti, vedi gestione errori.