Documentação
Eventos de domínio
A entidade registra o que aconteceu; o pipeline despacha depois do save, dentro da mesma transação.
A mecânica
Herde de AggregateRoot e chame Raise de dentro do modelo. Ninguém publica nada: o pipeline coleta os eventos das entidades alteradas depois do save e os despacha pelo mediator, na mesma transação.
public sealed class Product : AggregateRoot
{
public Product(string sku, decimal price)
{
Sku = sku;
Price = price;
// ExternalId já existe aqui; a chave do banco, não.
Raise(new ProductCreated(ExternalId, sku, price));
}
public void Reprice(decimal price)
{
if (price == Price) return; // nada mudou, nada a anunciar
var previous = Price;
Price = price;
Raise(new ProductRepriced(ExternalId, previous, price));
}
}O handler do evento é um IEventHandler<T> comum, descoberto pela varredura, e pode escrever no banco. O que ele escreve entra na transação do comando.
public sealed class PriceHistoryRecorder(
ICreate<PriceHistory> createPriceHistory,
IReadByExternalId<Product> productByExternalId,
TimeProvider timeProvider)
: IEventHandler<ProductCreated>, IEventHandler<ProductRepriced>
{
public Task HandleAsync(ProductCreated @event, CancellationToken ct) =>
RecordAsync(@event.ProductId, @event.Price, ct);
public Task HandleAsync(ProductRepriced @event, CancellationToken ct) =>
RecordAsync(@event.ProductId, @event.To, ct);
private async Task RecordAsync(Guid productId, decimal price, CancellationToken ct)
{
// O despacho é depois do save, então a chave já existe.
var product = await productByExternalId.ReadRequiredAsync(productId, ct);
await createPriceHistory.CreateAsync(
new PriceHistory
{
ProductId = product.Id,
Price = price,
RecordedOn = timeProvider.GetUtcNow(),
},
ct);
}
}Coletados antes do save, despachados depois
Despachar depois do save resolve o problema prático: nesse ponto as chaves geradas pelo banco existem, e o handler pode usá-las em chave estrangeira.
Mas a coleta acontece antes. Depois que um DELETE de verdade é gravado, a entidade sai do change tracker, e os eventos dela sairiam junto — um OrderCancelled de um pedido apagado simplesmente sumiria. Coletados antes, eles chegam aos handlers como qualquer outro, e o mesmo vale para uma entidade removida por um save imediato no meio do handler.
Mas ainda dentro da transação: se o handler do evento falhar, o comando inteiro é desfeito. Você nunca fica com o produto gravado e o histórico faltando.
abre transação
├─ handler do comando product.Reprice(25) → Raise(ProductRepriced)
├─ coleta [ProductRepriced]
├─ SaveChanges UPDATE product
├─ BroadcastAsync PriceHistoryRecorder → INSERT rastreado
├─ SaveChanges INSERT price_history
└─ commit as duas linhas, juntasEventos em cascata
Um handler de evento pode alterar entidades que registram novos eventos. O pipeline roda em rodadas: coleta, despacha, salva, coleta de novo. Cada evento é despachado exatamente uma vez, porque a coleta limpa o registro da entidade.
O limite é MaxDomainEventRounds, 4 por padrão. Estourar aborta a transação com uma mensagem explícita — quase sempre é um handler que dispara um evento que volta para ele mesmo.
nexo.AddUnitOfWorkPipeline(uow =>
{
uow.MaxDomainEventRounds = 4;
uow.DispatchDomainEvents = true; // false desliga a coleta por completo
});Eventos de domínio e eventos de integração
Um evento de domínio roda dentro da transação. Isso é exatamente o que você quer para consistência, e exatamente o que você não quer para publicar num broker: se o commit falhar depois de a mensagem sair, o mundo externo viu algo que não aconteceu.
O padrão para atravessar a fronteira é o outbox, e o Nexo traz um. Um evento que herda de IntegrationEvent não é despachado dentro da transação: ele é gravado numa tabela, na mesma transação do comando, e entregue depois do commit por um serviço em background.
public sealed class ProductPublished : IntegrationEvent
{
public Guid ProductId { get; init; }
public string Sku { get; init; } = string.Empty;
}
// Na entidade, igual a qualquer evento:
public void Publish() => Raise(new ProductPublished { ProductId = ExternalId, Sku = Sku });Broadcast direto
Eventos também podem ser publicados à mão, sem passar por entidade. Publicado de fora, o evento ganha seu próprio unit of work; publicado de dentro de um comando, entra no dele.
// De fora: este broadcast é o unit of work.
await mediator.BroadcastAsync(new NightlyRebuildRequested(), ct);
// De dentro de um handler: entra no unit of work do comando.
await mediator.BroadcastAsync(new CatalogPublished(version), ct);Publicar um evento que ninguém trata não é erro. Os pipelines rodam de qualquer forma, então o log continua registrando que o evento passou.