Nexo

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.

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

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

Texto
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, juntas

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

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

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

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