Domain Events and Consistency Boundaries
Objective
A domain event records something that happened inside an aggregate, named in
the language of the business: OrderPlaced, PaymentCaptured. Raising events
from the aggregate keeps the rule "when X happens, Y must follow" out of the
code that triggered X. The harder question is when the reactions run. Inside
the same transaction they share its atomicity and its failures. After the
commit they are eventually consistent and need a delivery guarantee. Choosing
between the two is a decision about consistency boundaries, and the aggregate
is the boundary.
Use Cases
- Placing an order must reserve stock, but Orders should not know how inventory works (a domain event handled in the same transaction or after it).
- A price change must reach Search and Notifications in other modules (a domain event translated into an integration event).
- Updating a read model or sending an email only if the order was really committed.
- Explaining to a product owner why the customer's loyalty points appear a few seconds after the order, not in the same instant.
Deep Dive
Raise events from the aggregate
The aggregate records events in a list instead of publishing them. It does not know about handlers, a bus, or the database:
plaintextpublic abstract class AggregateRoot { private readonly List<IDomainEvent> _events = []; public IReadOnlyList<IDomainEvent> DomainEvents => _events; protected void Raise(IDomainEvent e) => _events.Add(e); public void ClearDomainEvents() => _events.Clear(); } public sealed record OrderPlaced(OrderId OrderId, CustomerId CustomerId, DateTimeOffset At) : IDomainEvent; // Inside Order.Place(...), after the invariants pass: order.Raise(new OrderPlaced(order.Id, customerId, clock.GetUtcNow()));
The event is a fact in the past tense, immutable, and carries ids and the values the handlers need, not live entities.
Dispatch from a SaveChanges interceptor
An EF Core SaveChangesInterceptor sees every save, so the application code
never has to remember to publish. The tracker already knows which aggregates
changed, so collect their events there:
plaintextinternal sealed class DomainEventsInterceptor(IDomainEventDispatcher dispatcher) : SaveChangesInterceptor { public override async ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData data, InterceptionResult<int> result, CancellationToken ct = default) { if (data.Context is null) return result; var aggregates = data.Context.ChangeTracker.Entries<AggregateRoot>() .Select(e => e.Entity) .Where(a => a.DomainEvents.Count > 0) .ToList(); var events = aggregates.SelectMany(a => a.DomainEvents).ToList(); aggregates.ForEach(a => a.ClearDomainEvents()); foreach (var e in events) await dispatcher.DispatchAsync(e, ct); // runs before the commit, in the same unit of work return result; } }
Dispatching in SavingChangesAsync (before the commit) means handlers that
write to the same DbContext land in the same transaction: either everything is
saved or nothing is. EF Core invokes the interceptor before it runs
DetectChanges, so entities that a handler adds or changes there are picked up
by the same save. Dispatching in SavedChangesAsync (after the commit) means
the data is already durable, but a crash between the commit and the dispatch
loses the event.
Domain events vs integration events
A domain event is internal to the module and can carry rich types. An integration event is a public contract between modules, so it is versioned and carries only primitives. A handler in the owning module translates one into the other, and the translation writes to the outbox in the same transaction:
plaintextinternal sealed class OrderPlacedHandler(OrdersDbContext db) : IDomainEventHandler<OrderPlaced> { public Task HandleAsync(OrderPlaced e, CancellationToken ct) { db.OutboxMessages.Add(OutboxMessage.From( new OrderPlacedIntegrationEvent(e.OrderId.Value, e.CustomerId.Value, e.At))); return Task.CompletedTask; // saved together with the order by the surrounding SaveChanges } }
Because the outbox row is saved with the order, the integration event exists if and only if the order does. The dispatcher that reads the outbox is covered in the Module Communication and Architecture Tests concept.
One aggregate per transaction
The rule behind all of this: a transaction changes one aggregate. When a business operation needs to change two (placing an order also awards points to the customer), the second change happens in a separate transaction, triggered by an event. That makes the system eventually consistent between aggregates, and the design question becomes "how long can this stay out of sync, and what does the user see in the meantime?".
plaintext// Handled in its own transaction, after OrderPlaced was committed and delivered. public async Task HandleAsync(OrderPlacedIntegrationEvent e, CancellationToken ct) { var customer = await db.Customers.FindAsync([new CustomerId(e.CustomerId)], ct); customer!.RegisterOrder(new OrderId(e.OrderId)); await db.SaveChangesAsync(ct); }
Trade-offs
- In-transaction handlers couple failures. A handler that throws rolls back
the order, so a bug in loyalty points can block checkout.
Keep in-transaction handlers to work that truly belongs to the same consistency boundary, and move the rest behind the outbox.plaintextawait dispatcher.DispatchAsync(e, ct); // throws -> SaveChanges never commits the order - After-commit dispatch can lose events. Publishing in
SavedChangesAsyncwithout an outbox means a crash after the commit leaves the order saved and the event gone, with nothing to retry. - Handlers that call
SaveChangesre-enter the interceptor. A handler that saves insideSavingChangesAsynctriggers the interceptor again, so events raised there are dispatched recursively.plaintext// Safer: handlers only add to the context, and the single outer SaveChanges commits all. db.OutboxMessages.Add(message); // no SaveChanges here - Eventual consistency leaks into the user interface. After the order is placed, the points balance can lag, so screens either read from the source of truth for that moment or accept and communicate the delay.
- Events can become the hidden call graph. With many handlers per event, the flow of a use case is scattered across files and hard to follow in a debugger. Keep the number of reactions per event small and named after what they do.