Nexo

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

Texto
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
 └─ commit

Qualquer 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.

Auto-save por operação
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.
Unit of work
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.

C#
// É 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:

C#
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:

C#
[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.

C#
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.

Texto
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ê:

C#
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.

C#
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);
}
C#
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.