Articles · Mediator & CQRS ·003
Domain Events
Runnable sample on GitHub
Sample:
samples/003-domain-events— a console app whose domain events fan out to two handlers.dotnet runto see it,dotnet testto prove it.Prerequisites: 001 — Getting Started, 002 — First Aggregate.
Overview
In article 002 the Order aggregate raised domain events, but they just sat in a list — nothing
reacted to them. This article closes that loop. A domain event announces that something happened
inside an aggregate; domain-event handlers react to it; and Relay’s domain event bus connects
the two. One change to an aggregate can now trigger many independent reactions — update a read model,
send an alert, start the next step of a workflow — without the aggregate knowing any of them exist.
The headline property, which the rest of this article keeps returning to, is consistency: in a service with the persistence stack, domain events are dispatched inside the same transaction that saves the aggregate. The state change and everything it triggers commit together or not at all.
Why this exists
Without domain events, the logic that “should also happen” when an order is confirmed has nowhere good to live. Two bad options dominate:
- Stuff it into the command handler.
ConfirmOrderHandlerconfirms the order, and updates the read model, and enqueues a shipment, and writes an audit row, and notifies billing. The handler becomes a junk drawer; every new reaction edits it; and the order’s core decision is buried under its consequences. - Let callers remember to call things. “After you confirm an order, don’t forget to also call
ShipmentServiceandAuditService.” This is enforced by hope. Someone, somewhere, forgets, and now confirmed orders sometimes don’t get shipped.
Domain events invert the dependency. The aggregate announces OrderConfirmed; the shipment, audit
and read-model concerns each subscribe with their own handler. Adding a reaction means adding a
handler, not editing the confirm logic. Removing one means deleting a class. The aggregate stays
focused on its own rules, and the reactions stay focused on theirs.
Crucially, Relay dispatches these reactions transactionally with the change that caused them, which is the difference between a domain event and a fire-and-forget notification: you are not hoping the read model eventually catches up; it is updated in the same atomic unit as the write.
When to use this
Use a domain event when one change within a service should trigger other work within the same service, consistently:
- Order management.
OrderConfirmed→ update the “open orders” read model, decrement a projected stock count, append to the order’s audit trail. All inside this service, all atomic with the confirmation. - Payments.
PaymentCaptured→ update the payment read model and record a ledger entry in the same transaction, so the ledger can never disagree with the payment. - Inventory.
StockReserved→ update the available-to-promise projection immediately, so the next request sees the reservation. - Customer management.
SubscriptionRenewed→ recalculate the account’s projected next-bill-date read model. - Booking systems.
SeatReserved→ mark the seat unavailable in the seat-map read model the UI reads from.
The common shape: a write happens, and other state in the same service must reflect it immediately and reliably. That “immediately and reliably, in the same boundary” is exactly what domain events give you.
If the reaction belongs to a different service — “tell the warehouse system to pick this order” — that is an integration event, delivered asynchronously through the outbox and a broker, and is the subject of articles 011–014. Domain events stay inside one service’s boundary; integration events cross between services. Conflating the two is the single most common mistake (see below).
When not to use this
- The reaction is another service’s job. Cross-service communication must be asynchronous and resilient to the other service being down. A domain-event handler runs inside your transaction — if it makes an HTTP call to another system and that system is slow, you are holding a database transaction open across a network call. Use an integration event instead.
- The reaction can fail independently and must not roll back the write. If “send a welcome email” fails, you almost certainly do not want to roll back the user registration. A domain-event handler that throws aborts the whole transaction. Work that should survive its own failure belongs after the commit (an integration event / outbox), not in a domain-event handler.
- There is nothing to react to. A CRUD update with no downstream consequences does not need an
event. Do not raise
ThingUpdated“just in case”; events are a design tool, not a log. - Heavy or slow work. A handler that does expensive computation extends your transaction and your lock hold time. Keep domain-event handlers small and in-memory; push heavy or external work to a scheduled job (article 018) or an integration event.
The costs to weigh: domain events add indirection (“what runs when I confirm an order?” now requires finding the handlers), they put handler code inside your transaction (a slow or failing handler is a slow or failing write), and they invite misuse as cross-service messaging, which couples services through a shared transaction that does not actually span them.
Concepts
Domain event. A past-tense, immutable record of a fact (TicketOpened). It derives from
DomainEvent, which gives it an EventId, an OccurredAt, a per-aggregate Version, and optional
CausationId/CorrelationId. Aggregates raise them; they are part of the domain’s vocabulary.
Domain-event handler. A class implementing IDomainEventHandler<TEvent> — which is just
IMessageHandler<TEvent>, so it has one method, Task Handle(TEvent message, CancellationToken ct).
AddRelay discovers and registers them like any other handler. A single class can implement several
handler interfaces, and several classes can handle the same event (fan-out).
Domain event bus. IDomainEventBus publishes events to their handlers. Its key method for
aggregates is PublishAll(IEnumerable<IDomainEvent>, ct), which dispatches a heterogeneous batch of
events, each routed by its runtime type. In the full stack you rarely call it yourself — the
transaction does — but it is the mechanism underneath.
Causation and correlation. CorrelationId ties together every event that belongs to one logical
operation or workflow; CausationId points at the specific message that caused this event. Together
they let you reconstruct “what triggered what,” which is invaluable when debugging a chain of reactions
across handlers.
Domain vs integration event. A domain event is in-process and in-transaction — same service, same atomic unit. An integration event is out-of-process and eventually consistent — published via the outbox after commit and delivered to other services through a broker. Same idea (“announce a fact, let others react”), opposite delivery guarantees.
Architecture
In a service with the persistence stack, the flow is automatic and atomic:
sequenceDiagram
participant H as ConfirmOrderHandler
participant Tx as TransactionBehavior (priority 1000)
participant Agg as Order aggregate
participant Bus as IDomainEventBus
participant DH1 as ReadModelProjector
participant DH2 as AuditHandler
participant ES as Event store + DB
Tx->>Tx: BEGIN TRANSACTION
Tx->>H: Handle(ConfirmOrderCommand)
H->>Agg: order.Confirm() (raises OrderConfirmed)
Tx->>Bus: PublishAll(order's uncommitted events)
Bus->>DH1: Handle(OrderConfirmed) — update read model (same DbContext)
Bus->>DH2: Handle(OrderConfirmed) — write audit row (same DbContext)
Tx->>ES: append events + commit ALL atomically
Note over Tx,ES: handler throws ⇒ whole transaction rolls back
The Transaction behavior (priority 1000, the innermost wrapper from article 001) begins a
transaction, runs the handler, then dispatches every uncommitted domain event from every tracked
aggregate to its handlers — on the same scope and DbContext, so the handlers’ writes join the same
transaction. Because handlers can themselves load aggregates and raise more events, the dispatcher
loops until no new events remain (with a guard — currently 25 rounds — that throws if a handler keeps
raising events forever, catching accidental infinite cascades). Then it appends the events and commits
everything as one atomic unit.
The sample in this article has no persistence stack, so it makes one piece explicit that the
transaction would otherwise do for you: the command handler calls IDomainEventBus.PublishAll(...)
itself. The dispatch, fan-out, and handler mechanics are identical — only the “who calls publish and
when does it commit” differs.
graph LR
Cmd[OpenTicketCommandHandler] -->|raises| Ev[TicketOpened]
Ev --> P[TicketReadModelProjector]
Ev --> A[HighPriorityTicketAlerter]
P --> RM[(read model)]
A --> AL[(alert log)]
Building it step by step
The full code is in samples/003-domain-events.
1. The events
Ordinary DomainEvent records, as in article 002:
public sealed record TicketOpened(Guid TicketId, string Subject, string Priority) : DomainEvent
{
public override Guid AggregateId => TicketId;
}
2. The aggregate raises them, and correlates the chain
public static Ticket Open(Guid id, string subject, string priority)
{
Guard.AgainstEmptyGuid(id, nameof(id));
var ticket = new Ticket();
ticket.RaiseEvent(new TicketOpened(id, subject.Trim(), priority.Trim().ToLowerInvariant())
{
CorrelationId = id // group everything that ever happens to this ticket
});
return ticket;
}
3. Handlers react — and they fan out
A single class can keep an entire read model in sync by handling several event types:
public sealed class TicketReadModelProjector(TicketReadModelStore store)
: IDomainEventHandler<TicketOpened>,
IDomainEventHandler<TicketAssigned>,
IDomainEventHandler<TicketClosed>
{
public Task Handle(TicketOpened message, CancellationToken ct)
{
store.Upsert(new TicketView(message.TicketId, message.Subject, message.Priority, "Open", Assignee: null));
return Task.CompletedTask;
}
// … Handle(TicketAssigned), Handle(TicketClosed)
}
And a second, independent handler reacts to the same TicketOpened event for a different reason
— proof that events fan out and that reactions stay decoupled:
public sealed class HighPriorityTicketAlerter(AlertLog alerts) : IDomainEventHandler<TicketOpened>
{
public Task Handle(TicketOpened message, CancellationToken ct)
{
if (string.Equals(message.Priority, "high", StringComparison.OrdinalIgnoreCase))
alerts.Add($"High-priority ticket opened: \"{message.Subject}\"");
return Task.CompletedTask;
}
}
4. Dispatch
In this DB-free sample the command handler publishes the events itself, then marks them committed:
var ticket = Ticket.Open(Guid.NewGuid(), command.Subject, command.Priority);
tickets.Add(ticket);
var pending = ticket.GetUncommittedChanges().ToList();
await events.PublishAll(pending, cancellationToken); // → both handlers run
ticket.MarkChangesAsCommitted();
In a real service, delete those three lines: the transactional pipeline (article 005) dispatches the aggregate’s events for you, inside the transaction. The handlers above do not change at all.
Complete source code
| File | Contents |
|---|---|
Tickets/Ticket.cs |
The aggregate that raises the events |
Tickets/TicketEvents.cs |
The domain events |
Tickets/DomainEventHandlers.cs |
Projector + alerter (IDomainEventHandler<T>) |
Tickets/Commands.cs |
Commands + handlers that publish via IDomainEventBus |
Tickets/Stores.cs |
In-memory aggregate store, read model, alert log |
Running the example
dotnet run --project samples/003-domain-events/Fulfillment
Ticket "Cannot log in" is now Closed, assigned to agent-amy.
ALERT: High-priority ticket opened: "Cannot log in" (correlation 8f3c…)
The command handlers never touched the read model or the alert log — those were updated entirely by the domain-event handlers reacting to the events the aggregate raised.
Testing
The sample’s TicketFlowTests.cs
builds the real container and dispatches commands through ICommandBus, then asserts the handlers’
effects — which is the only honest way to test “did the event reach its reactors?”
[Fact]
public async Task Opening_a_high_priority_ticket_projects_a_read_model_and_raises_an_alert()
{
using var provider = BuildProvider();
var commands = provider.GetRequiredService<ICommandBus>();
var id = await commands.Execute<OpenTicketCommand, Guid>(new OpenTicketCommand("Cannot log in", "high"), default);
provider.GetRequiredService<TicketReadModelStore>().Get(id).Should().NotBeNull(); // projector ran
provider.GetRequiredService<AlertLog>().All.Should().ContainSingle(); // alerter ran (same event)
}
The fan-out test exists because a regression where a second handler stops being invoked is invisible to a test that only checks the first. The “low priority does not alert” test pins the alerter’s condition. And the rejection test proves an invariant violation in a handler-triggering command propagates out rather than silently half-applying:
[Fact]
public async Task Closing_an_unassigned_ticket_is_rejected_by_the_aggregate()
{
using var provider = BuildProvider();
var commands = provider.GetRequiredService<ICommandBus>();
var id = await commands.Execute<OpenTicketCommand, Guid>(new OpenTicketCommand("Cannot log in", "high"), default);
var act = () => commands.Execute<CloseTicketCommand>(new CloseTicketCommand(id, "premature"), default);
await act.Should().ThrowAsync<DomainException>();
}
dotnet test samples/003-domain-events/Fulfillment.Tests
Production considerations
- Atomicity is the feature — protect it. The reason to use domain events over a fire-and-forget notification is that the reaction commits with the cause. Keep handlers in-transaction-safe: in-memory updates and DB writes on the shared context, yes; network calls, no.
- Idempotency still matters. A command can be retried after a transient failure, re-raising the same events. Write handlers so that applying an event twice is harmless (upsert, not blind insert) — the same discipline projections require (article 007).
- Loop protection. Handlers may raise further events (a saga-like cascade). Relay re-dispatches until quiescent but throws after a bounded number of rounds to catch infinite cascades. If you hit that guard, you have an accidental cycle (A raises B, B raises A); break it, do not raise the limit.
- Ordering. Handlers for a batch run sequentially on one scope; do not rely on two different handlers running in a particular order — if order matters, that is a sign the work belongs in one handler or a saga, not two racing reactions.
- Observability. Set
CorrelationIdon the events an operation raises (the sample uses the aggregate id). Combined with Relay’s telemetry (article 021), this lets you trace a single user action through every handler it triggered. - The migration to event sourcing is a deletion. When you adopt the persistence stack, you delete
the manual
PublishAll+MarkChangesAsCommittedfrom your handlers; the transaction does it. The handlers themselves — the valuable part — are unchanged. Design them now as if the transaction is already there.
Common mistakes
- Using a domain event to talk to another service. A domain-event handler that calls another system’s API runs inside your transaction and couples your commit to their availability. Cross a service boundary with an integration event (outbox → broker, articles 011–014), which is asynchronous and durable.
- Doing must-not-roll-back work in a handler. “Send the welcome email” inside the registration transaction means a flaky mail server rolls back the registration. Anything that should survive its own failure goes after commit, via the outbox — not in a domain-event handler.
- Anaemic events again.
TicketChanged { string Field }forces every handler to branch on field names and re-derive intent. Name events for what happened (TicketAssigned), so handlers can pattern match and so the event is meaningful in a log. - Raising events nothing handles, forever. An event with no handlers is dead weight and misleading documentation. Raise events because something reacts (now or imminently), not speculatively.
- Fat handlers. A handler that does five unrelated things is the command-handler junk drawer relocated. One handler, one reaction; let fan-out give you the rest.
Tradeoffs
Benefits. Reactions are decoupled from the change that triggers them; new reactions are new classes, not edits to existing logic; and — uniquely versus plain notifications — the reactions commit atomically with the cause, so read models and ledgers cannot drift from the write.
Costs. Indirection (the set of things that run on a change is no longer linear in the handler); handler code executes inside your transaction, so a bad handler is a bad write; and the pattern is easy to misapply as cross-service messaging, creating a transaction that pretends to span services.
Alternatives
- Inline orchestration in the command handler. Explicit and easy to read for one or two reactions; becomes a junk drawer and a merge-conflict magnet as reactions multiply. Fine until it isn’t.
- An in-memory event aggregator /
INotification(e.g. MediatR notifications). The same fan-out shape, but typically not enlisted in your transaction — the notification may run outside the database commit, so a failed reaction does not roll back the write. That is sometimes what you want (best-effort side effects) and sometimes a silent consistency bug. Relay’s in-transaction dispatch is the deliberate, consistency-preserving choice; choose a non-transactional aggregator only when you specifically want best-effort decoupling. - Integration events for everything. Treat every reaction as cross-service and async. Maximally decoupled, but you trade strong consistency for eventual consistency even within one service, and pay the operational cost of a broker for work that never left the process. Reserve it for work that actually crosses a boundary.
Objectively: domain events win when the reaction must be consistent with and inside the same service as the change. They are the wrong tool the moment either of those stops being true.
Lessons from production systems
- The domain/integration-event boundary is where architectures succeed or rot. Teams that keep domain events strictly in-process and route all cross-service reactions through the outbox get services that stay independently deployable. Teams that let a domain-event handler “just call the other service” build a distributed monolith whose transactions silently span systems and whose outages cascade.
- Handlers running in the transaction is a double-edged sword. It is the source of the consistency guarantee and the source of “why is this write slow / failing?” Treat domain-event handlers with the same scrutiny as the handler itself: fast, deterministic, idempotent, no I/O you would not put in the command.
- Correlation ids pay for themselves the first time a cascade misbehaves. When confirming an order triggers six handlers and one does the wrong thing, a correlation id turns “grep the logs and guess” into “filter to this id and read the chain.”
- Most “events” teams add are never handled. Resist the urge to emit an event for every state change. An event earns its place when something reacts to it; otherwise it is noise that future readers must investigate before concluding it does nothing.
Should you use this?
| Situation | Recommendation |
|---|---|
| One change must update other state in the same service, consistently | Strong fit — this is the core use case |
| Keeping a read model in sync with an aggregate’s changes | Strong fit — and it becomes a projection later (article 007) |
| Maintaining an audit trail / ledger atomic with the write | Strong fit |
| Telling another service to do something | Avoid — use an integration event (outbox), articles 011–014 |
| Work that must survive its own failure (emails, third-party calls) | Avoid in a handler — do it after commit via the outbox |
| A change with no downstream consequences | Avoid — don’t raise an event nothing handles |
Next steps
You now have a robust pattern for in-process reactions — but those reactions, and the events that drive them, are still ephemeral. The next article makes the events durable: instead of saving the aggregate’s current state, we save the stream of events and rebuild state by replaying them. That is event sourcing, and the apply-only, replay-safe aggregate from article 002 is already ready for it.
➡️ 004 — Errors & HTTP (planned) · then 005 — Event Sourcing Basics (planned — see the coverage plan)