Documentação
Unit of work
Por que não existe SaveChanges no handler, e o que exatamente o pipeline faz em volta dele.
O ciclo de um comando
abre transação (se o provider suportar, usando a execution strategy dele)
├─ handler
├─ coleta os eventos de domínio ← antes do save: uma entidade removida ainda está rastreada
├─ SaveChanges ← uma vez, depois que o handler retorna
├─ despacha os eventos ← dentro desta mesma transação
│ └─ eventos de integração vão para o outbox, se ele estiver ligado
├─ SaveChanges do que os handlers de evento escreveram
└─ commitQualquer exceção em qualquer ponto desfaz tudo. O handler não tem try/catch de transação, não chama SaveChanges e não sabe que existe uma transação.
Os eventos são coletados antes do save por um motivo concreto: depois que um DELETE de verdade é gravado, a entidade sai do change tracker e leva os eventos com ela. Coletados antes, o OrderCancelled de um pedido apagado chega aos handlers como qualquer outro.
Por que salvar uma vez, e não a cada operação
A alternativa óbvia — cada ICreate salvando sozinho — é o que uma camada de auto-save ingênua faz. Parece conveniente e entrega duas coisas ruins: N idas ao banco, e escrita não atômica.
CreateAsync(produto) → INSERT + COMMIT
CreateAsync(histórico) → INSERT + COMMIT
CreateAsync(auditoria) → falha
Resultado: produto e histórico gravados,
auditoria não. Estado inconsistente, sem
nada no log dizendo isso.CreateAsync(produto) → só rastreia
CreateAsync(histórico) → só rastreia
CreateAsync(auditoria) → falha
Resultado: rollback. O banco está
exatamente como antes do comando.
Uma ida ao banco no caminho feliz.É por isso que SaveBehavior.UnitOfWork é o padrão. O modo Immediate existe para script de migração de dados e utilitário de console, onde não há pipeline nenhum — e a documentação dele diz exatamente o que você perde.
Transação e execution strategy
Quando há transação, ela passa pela execution strategy do provider. Isso importa por dois motivos: uma falha transitória reexecuta a operação inteira, e não metade dela; e o EF Core lança exceção se você abrir transação na mão tendo uma estratégia com retry configurada.
// É isso que o pipeline chama. Você pode chamar também, se precisar de um
// bloco transacional fora de um comando.
var report = await unitOfWork.ExecuteInTransactionAsync(async token =>
{
await create.CreateAsync(new Snapshot(), token);
return await BuildReportAsync(token);
}, ct);É reentrante: se já existe transação aberta, a operação entra na existente em vez de aninhar.
Uma nova tentativa começa do zero. Se o save falha, as entidades adicionadas continuam rastreadas e o handler as adicionaria de novo; se o commit falha, elas já foram salvas e aceitas, e o handler veria "nada a mudar" e reportaria sucesso sem gravar. Por isso, antes de cada nova tentativa, o change tracker de todos os contextos é limpo, e o handler roda sobre um estado limpo.
Resta um caso que nenhum retry resolve sozinho: a conexão cai durante o commit, e não dá para saber se ele aconteceu. Registre um ICommitVerifier que responda a pergunta — em geral, procurando no banco algo que o comando grava — e o Nexo usa a resposta em vez de repetir o comando:
public sealed class OrderCommitVerifier(IQueryable<Order> orders, CurrentOrder current)
: ICommitVerifier
{
public Task<bool> HasCommittedAsync(CancellationToken ct) =>
orders.IgnoreQueryFilters().AnyAsync(o => o.ExternalId == current.Id, ct);
}
services.AddScoped<ICommitVerifier, OrderCommitVerifier>();Um comando que não deve rodar em transação — um import longo que grava em lotes, por exemplo — diz isso com um atributo. Ele ainda é salvo no fim; só não há transação em volta:
[NoTransaction]
public sealed class ImportCatalog : Command<int>
{
public required string FileName { get; init; }
}Reentrância: eventos dentro de comandos
Um handler de comando que chama BroadcastAsync faz a mensagem passar pelo pipeline de novo. Sem cuidado, a segunda passagem tentaria abrir uma transação aninhada e salvar no meio do comando.
O Nexo guarda, num AsyncLocal, qual unit of work está em andamento. Ele acompanha a cadeia de await e não deixa uma alteração feita numa chamada aninhada escapar de volta para quem chamou. Uma passagem pelo pipeline que encontra o seu próprio unit of work já em andamento entra nele; só a mais externa é dona do save e do commit.
Guardar a instância, e não só "estou dentro de um comando", importa num caso: um handler que cria um escopo de DI novo e manda um comando por ele. Esse comando tem outro DbContext, que o unit of work de fora nunca salvaria — então ele ganha um unit of work próprio, salva e comita sozinho.
public async Task HandleAsync(PublishCatalog command, CancellationToken ct)
{
await create.CreateAsync(new CatalogSnapshot(command.Version), ct);
// Entra no unit of work deste comando. Não abre transação nova,
// não salva pela metade.
await mediator.BroadcastAsync(new CatalogPublished(command.Version), ct);
}Queries nunca salvam
O pipeline não salva nada para uma query. Se um handler de query deixar mudanças pendentes, ele registra um warning nomeando a query — que quase sempre é uma escrita que vazou para o lado de leitura.
warn: Nexo.Pipelines.UnitOfWorkPipeline[1004]
Query SearchProducts left unsaved changes behind; queries are never saved.
A write has most likely leaked into the read side.Desligue com uow.WarnOnPendingQueryChanges = false se o ruído não valer para você.
O escopo é a fronteira
Um unit of work é scoped: todas as operações resolvidas no mesmo escopo de DI compartilham a mesma sessão, e é isso que faz ICreate<A> e ICreate<B> somarem numa única escrita atômica.
Em ASP.NET Core o escopo é a requisição, e não há o que fazer. Num worker ou consumidor de fila, crie o escopo você:
while (!stoppingToken.IsCancellationRequested)
{
var message = await queue.ReceiveAsync(stoppingToken);
// Um escopo por mensagem: um DbContext, um unit of work, uma transação.
await using var scope = serviceProvider.CreateAsyncScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.SendAsync(message.ToCommand(), stoppingToken);
}Quando você quer o controle
IUnitOfWork está lá para os casos raros: um comando longo que precisa de checkpoints, ou código fora do mediator.
public interface IUnitOfWork
{
bool HasChanges { get; }
bool SupportsTransactions { get; }
bool HasActiveTransaction { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task<IDataTransaction> BeginTransactionAsync(CancellationToken ct = default);
Task<TResult> ExecuteInTransactionAsync<TResult>(
Func<CancellationToken, Task<TResult>> operation,
CancellationToken ct = default);
}await using var transaction = await unitOfWork.BeginTransactionAsync(ct);
await create.CreateAsync(new Product("A1", 10m), ct);
// CommitAsync salva o pendente e faz commit — não é preciso lembrar de salvar antes.
await transaction.CommitAsync(ct);RollbackAsync também limpa o change tracker: deixar as mudanças rastreadas permitiria que um save posterior as ressuscitasse. Chamado dentro de um comando, BeginTransactionAsync devolve uma transação que entra na do comando: o CommitAsync dela não comita o trabalho do comando, e sair do bloco sem comitar não o desfaz.