Domain Events e Fronteiras de Consistência
Objective
Um domain event registra algo que aconteceu dentro de um aggregate, nomeado na
linguagem do negócio: OrderPlaced, PaymentCaptured. Levantar eventos a
partir do aggregate mantém a regra "quando X acontece, Y deve se seguir" fora do
código que disparou X. A pergunta mais difícil é quando as reações rodam.
Dentro da mesma transação, elas compartilham sua atomicidade e suas falhas.
Depois do commit, são eventualmente consistentes e precisam de uma garantia de
entrega. Escolher entre as duas é uma decisão sobre fronteiras de consistência,
e o aggregate é a fronteira.
Use Cases
- Fazer um pedido precisa reservar estoque, mas Orders não deve saber como o inventário funciona (um domain event tratado na mesma transação ou depois dela).
- Uma mudança de preço precisa chegar a Search e Notifications em outros módulos (um domain event traduzido em um integration event).
- Atualizar um modelo de leitura ou enviar um e-mail só se o pedido foi mesmo confirmado.
- Explicar a um product owner por que os pontos de fidelidade do cliente aparecem alguns segundos depois do pedido, e não no mesmo instante.
Deep Dive
Levante eventos a partir do aggregate
O aggregate registra eventos numa lista em vez de publicá-los. Ele não conhece handlers, um bus nem o banco de dados:
csharppublic abstract class AggregateRoot
{
private readonly List<IDomainEvent> _events = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _events;
protected void Raise(IDomainEvent e) => _events.Add(e);
public void ClearDomainEvents() => _events.Clear();
}
public sealed record OrderPlaced(OrderId OrderId, CustomerId CustomerId, DateTimeOffset At) : IDomainEvent;
// Dentro de Order.Place(...), depois que as invariantes passam:
order.Raise(new OrderPlaced(order.Id, customerId, clock.GetUtcNow()));O evento é um fato no passado, imutável, e carrega ids e os valores de que os handlers precisam, não entities vivas.
Despache a partir de um interceptor do SaveChanges
Um SaveChangesInterceptor do EF Core enxerga todo save, então o código da
aplicação nunca precisa lembrar de publicar. O tracker já sabe quais aggregates
mudaram, então colete os eventos deles ali:
csharpinternal sealed class DomainEventsInterceptor(IDomainEventDispatcher dispatcher) : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData data, InterceptionResult<int> result, CancellationToken ct = default)
{
if (data.Context is null) return result;
var aggregates = data.Context.ChangeTracker.Entries<AggregateRoot>()
.Select(e => e.Entity)
.Where(a => a.DomainEvents.Count > 0)
.ToList();
var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
aggregates.ForEach(a => a.ClearDomainEvents());
foreach (var e in events)
await dispatcher.DispatchAsync(e, ct); // roda antes do commit, na mesma unidade de trabalho
return result;
}
}Despachar em SavingChangesAsync (antes do commit) significa que handlers que
escrevem no mesmo DbContext caem na mesma transação: ou tudo é salvo, ou nada. O EF Core chama o interceptor
antes de executar DetectChanges, então entidades que um handler adiciona ou
altera ali entram no mesmo save.
Despachar em SavedChangesAsync (depois do commit) significa que o dado já é
durável, mas uma queda entre o commit e o despacho perde o evento.
Domain events vs integration events
Um domain event é interno ao módulo e pode carregar tipos ricos. Um integration event é um contrato público entre módulos, então é versionado e carrega apenas primitivos. Um handler no módulo dono traduz um no outro, e a tradução escreve no outbox na mesma transação:
csharpinternal sealed class OrderPlacedHandler(OrdersDbContext db) : IDomainEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct)
{
db.OutboxMessages.Add(OutboxMessage.From(
new OrderPlacedIntegrationEvent(e.OrderId.Value, e.CustomerId.Value, e.At)));
return Task.CompletedTask; // salvo junto com o pedido pelo SaveChanges externo
}
}Como a linha do outbox é salva junto com o pedido, o integration event existe se, e somente se, o pedido existe. O dispatcher que lê o outbox é tratado no conceito Module Communication and Architecture Tests.
Um aggregate por transação
A regra por trás de tudo isso: uma transação muda um aggregate. Quando uma operação de negócio precisa mudar dois (fazer um pedido também dá pontos ao cliente), a segunda mudança acontece numa transação separada, disparada por um evento. Isso torna o sistema eventualmente consistente entre aggregates, e a pergunta de design vira "por quanto tempo isso pode ficar fora de sincronia, e o que o usuário vê nesse meio-tempo?".
csharp// Tratado em sua própria transação, depois que OrderPlaced foi confirmado e entregue.
public async Task HandleAsync(OrderPlacedIntegrationEvent e, CancellationToken ct)
{
var customer = await db.Customers.FindAsync([new CustomerId(e.CustomerId)], ct);
customer!.RegisterOrder(new OrderId(e.OrderId));
await db.SaveChangesAsync(ct);
}Trade-offs
- Handlers dentro da transação acoplam as falhas. Um handler que lança
exceção desfaz o pedido, então um bug nos pontos de fidelidade pode bloquear o
checkout.
Mantenha nos handlers dentro da transação só o trabalho que realmente pertence à mesma fronteira de consistência, e mova o resto para trás do outbox.csharpawait dispatcher.DispatchAsync(e, ct); // lança -> o SaveChanges nunca confirma o pedido - Despacho depois do commit pode perder eventos. Publicar em
SavedChangesAsyncsem um outbox significa que uma queda depois do commit deixa o pedido salvo e o evento perdido, sem nada para tentar de novo. - Handlers que chamam
SaveChangesreentram no interceptor. Um handler que salva dentro deSavingChangesAsyncaciona o interceptor de novo, então eventos levantados ali são despachados recursivamente.csharp// Mais seguro: handlers só adicionam ao contexto, e o único SaveChanges externo confirma tudo. db.OutboxMessages.Add(message); // sem SaveChanges aqui - A consistência eventual vaza para a interface. Depois que o pedido é feito, o saldo de pontos pode atrasar, então as telas ou leem da fonte da verdade naquele momento ou aceitam e comunicam o atraso.
- Eventos podem virar o grafo de chamadas escondido. Com muitos handlers por evento, o fluxo de um caso de uso fica espalhado por vários arquivos e difícil de seguir num debugger. Mantenha pequeno o número de reações por evento e nomeie-as pelo que fazem.