Skip to content

Domain Events and Unit of Work

A. Shafie edited this page May 28, 2026 · 2 revisions

Domain Events and Unit of Work

LiteBus supports POCO events, so domain projects can publish facts without referencing LiteBus abstractions. Keep the collection of events in the domain model and dispatch them from the application layer near the transaction boundary.

Domain Event Contracts

public interface IDomainEvent
{
    DateTime OccurredAtUtc { get; }
}

public interface IHasDomainEvents
{
    IReadOnlyCollection<IDomainEvent> DomainEvents { get; }
    void ClearDomainEvents();
}

Aggregates can expose read-only events and keep mutation methods private.

public abstract class AggregateRoot : IHasDomainEvents
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;

    protected void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

public sealed record OrderPlaced(Guid OrderId, DateTime OccurredAtUtc) : IDomainEvent;

Dispatch Timing

There are three common timing choices.

Timing Use when Trade-off
Before commit Handlers must join the same transaction Handler failures roll back the command
After commit Handlers can run after state is saved Handler failures need retry logic
Outbox Publication must survive process failure Requires an outbox table and processor

The outbox is the safest default for events that leave the process or update state outside the current aggregate. Store the outbox row in the same transaction as the aggregate change, then publish from a background worker.

EF Core Interceptor Shape

The application layer can collect events from tracked aggregates in a SaveChangesInterceptor or in a unit-of-work wrapper.

public sealed class DomainEventCollector
{
    public IReadOnlyCollection<IDomainEvent> Collect(DbContext dbContext)
    {
        var aggregates = dbContext.ChangeTracker
            .Entries<IHasDomainEvents>()
            .Select(entry => entry.Entity)
            .Where(entity => entity.DomainEvents.Count > 0)
            .ToArray();

        var events = aggregates.SelectMany(entity => entity.DomainEvents).ToArray();

        foreach (var aggregate in aggregates)
        {
            aggregate.ClearDomainEvents();
        }

        return events;
    }
}

For outbox use, map domain events to integration events and store them through IIntegrationOutbox before the transaction commits. For in-process dispatch, publish after SaveChangesAsync returns.

await integrationOutbox.AddAsync(
    new OrderSubmittedIntegrationEvent
    {
        EventId = Guid.NewGuid(),
        OrderId = order.Id
    },
    new OutboxOptions
    {
        Topic = "orders"
    },
    cancellationToken);

Command Pipeline Boundary

Use open generic command pre-handlers and post-handlers for unit-of-work concerns.

public sealed class TransactionCommandPreHandler<TCommand> : ICommandPreHandler<TCommand>
    where TCommand : ICommand
{
    public Task PreHandleAsync(TCommand command, CancellationToken cancellationToken)
    {
        return _unitOfWork.BeginAsync(cancellationToken);
    }
}

public sealed class TransactionCommandPostHandler<TCommand> : ICommandPostHandler<TCommand>
    where TCommand : ICommand
{
    public Task PostHandleAsync(TCommand command, object? result, CancellationToken cancellationToken)
    {
        return _unitOfWork.CommitAsync(cancellationToken);
    }
}

Run validation before opening a transaction when validation does not need database locks. Persist domain events to an outbox in the same transaction as the aggregate change when publication matters after a process crash.

CQS Rules

Command handlers can change state and publish domain events. Query handlers should read state only. A query handler should not depend on ICommandMediator, publish events, or write through repositories. Event handlers should be idempotent when retries or durability are used.

Clone this wiki locally