-
Notifications
You must be signed in to change notification settings - Fork 16
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.
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;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.
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);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.
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.