Nexo

Documentation

Domain events

The entity records what happened; the pipeline dispatches after the save, inside the same transaction.

The mechanics

Derive from AggregateRoot and call Raise from inside the model. Nobody publishes anything: the pipeline collects the events from the changed entities after the save and dispatches them through the mediator, in the same transaction.

C#
public sealed class Product : AggregateRoot
{
    public Product(string sku, decimal price)
    {
        Sku = sku;
        Price = price;

        // ExternalId already exists here; the database key does not.
        Raise(new ProductCreated(ExternalId, sku, price));
    }

    public void Reprice(decimal price)
    {
        if (price == Price) return;   // nothing changed, nothing to announce

        var previous = Price;
        Price = price;
        Raise(new ProductRepriced(ExternalId, previous, price));
    }
}

The event handler is an ordinary IEventHandler<T>, found by scanning, and it can write to the database. What it writes joins the command's transaction.

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)
    {
        // Dispatch happens after the save, so the key already exists.
        var product = await productByExternalId.ReadRequiredAsync(productId, ct);

        await createPriceHistory.CreateAsync(
            new PriceHistory
            {
                ProductId = product.Id,
                Price = price,
                RecordedOn = timeProvider.GetUtcNow(),
            },
            ct);
    }
}

Collected before the save, dispatched after

Dispatching after the save solves the practical problem: at that point the database-generated keys exist, and the handler can use them in a foreign key.

But collection happens before. Once a real DELETE is written, the entity leaves the change tracker and its events would leave with it — an OrderCancelled for a deleted order would simply vanish. Collected beforehand, they reach the handlers like any other, and the same holds for an entity removed by an immediate save in the middle of the handler.

But still inside the transaction: if the event handler throws, the whole command is undone. You never end up with the product written and the history missing.

Text
open the transaction
 ├─ command handler        product.Reprice(25)  → Raise(ProductRepriced)
 ├─ collect                [ProductRepriced]
 ├─ SaveChanges            UPDATE product
 ├─ BroadcastAsync         PriceHistoryRecorder → tracked INSERT
 ├─ SaveChanges            INSERT price_history
 └─ commit                 both rows, together

Cascading events

An event handler can change entities that record new events. The pipeline runs in rounds: collect, dispatch, save, collect again. Each event is dispatched exactly once, because collecting clears the entity's record.

The limit is MaxDomainEventRounds, 4 by default. Blowing through it aborts the transaction with an explicit message — it is nearly always a handler raising an event that comes back to itself.

C#
nexo.AddUnitOfWorkPipeline(uow =>
{
    uow.MaxDomainEventRounds = 4;
    uow.DispatchDomainEvents = true;   // false turns collection off entirely
});

Domain events and integration events

A domain event runs inside the transaction. That is exactly what you want for consistency, and exactly what you do not want for publishing to a broker: if the commit fails after the message has left, the outside world saw something that did not happen.

The pattern for crossing that boundary is the outbox, and Nexo ships one. An event deriving from IntegrationEvent is not dispatched inside the transaction: it is written to a table, in the command's own transaction, and delivered after the commit by a background service.

C#
public sealed class ProductPublished : IntegrationEvent
{
    public Guid ProductId { get; init; }
    public string Sku { get; init; } = string.Empty;
}

// On the entity, like any other event:
public void Publish() => Raise(new ProductPublished { ProductId = ExternalId, Sku = Sku });

Broadcasting directly

Events can also be published by hand, without going through an entity. Published from outside, the event gets its own unit of work; published from inside a command, it joins that one.

C#
// From outside: this broadcast is the unit of work.
await mediator.BroadcastAsync(new NightlyRebuildRequested(), ct);

// From inside a handler: joins the command's unit of work.
await mediator.BroadcastAsync(new CatalogPublished(version), ct);

Publishing an event nobody handles is not an error. The pipelines run anyway, so the log still records that the event passed through.