Why It Matters
An order is placed. Inventory must be reserved, a confirmation must be prepared, an audit entry must be written, and another service may need to start fulfillment.
The difficult question is not how to call four methods. It is where those reactions belong, which reactions share a transaction, and what happens when one of them fails.
Putting every reaction inside PlaceOrder() makes the aggregate depend on email, caches, databases, and message brokers. Moving every reaction to a broker makes a local business rule depend on a distributed system. Domain Events give the model a third option: record a business fact inside the domain, then let application-level handlers coordinate the consequences.
This article explains that option from its historical roots to a production flow using aggregates, Entity Framework Core, MediatR, and the Transactional Outbox pattern.
The diagram is a map, not a universal transaction prescription. The correct commit boundary depends on whether a reaction must be atomic with the original state change. The sections below make that boundary explicit.
Background
From object models to explicit business facts
Domain-Driven Design (DDD) established a way to model software around business language, bounded contexts, entities, value objects, services, and aggregates. That model answered an important question: where should business behavior live?
It did not, by itself, remove the need for one part of a domain to react when another part changed. Those reactions often appeared as direct method calls, service orchestration, database triggers, or infrastructure code embedded in entities.
In 2005, Martin Fowler documented the Domain Event pattern as a way to capture the memory of something interesting that affects the domain. Later DDD writing and production examples refined the pattern toward immutable, past-tense business facts such as OrderPlaced, PaymentAuthorized, and ShipmentDispatched.
During the late 2000s and 2010s, three implementation ideas became common:
- Aggregates raise facts instead of invoking infrastructure.
- A dispatcher maps each fact to zero or more handlers.
- Dispatch is deferred until an application or persistence boundary can control transaction behavior.
Mediator libraries such as MediatR made in-process dispatch convenient in .NET. Microservice adoption also forced a sharper distinction between an in-process Domain Event and a durable Integration Event sent across process boundaries.
The pattern is older than the tools
MediatR does not create Domain Events, and a message broker does not define them. Domain Events are a modeling decision. MediatR is one possible in-process dispatcher. RabbitMQ, Kafka, or a cloud service bus can carry Integration Events, but they should not be required for an aggregate to describe what happened.
That separation matters because tools change faster than business language.
Problem
Without an explicit event, an application service often accumulates a sequence like this:
public async Task<Guid> Handle(PlaceOrder command)
{
var order = Order.Place(command.CustomerId, command.Lines);
await orderRepository.Add(order);
await inventory.Reserve(order);
await email.SendConfirmation(order);
await cache.RemoveCustomerOrders(command.CustomerId);
await messageBus.Publish(new OrderPlacedIntegrationEvent(order.Id));
await unitOfWork.SaveChangesAsync();
return order.Id;
}
This code hides several independent decisions:
- Is inventory part of the same business consistency boundary?
- Should an SMTP outage prevent the order from being saved?
- What if the broker accepts the message but the database commit fails?
- Can the email or message be processed twice?
- Which line expresses a domain rule, and which line is merely an application reaction?
The method also becomes the change point for every future reaction. Adding fraud scoring, loyalty credit, or analytics means modifying the use case even if placing an order itself has not changed.
Domain Events solve the coupling and visibility problem. They do not automatically solve delivery, ordering, idempotency, or distributed transactions. Those require explicit design.
Core Concepts
A Domain Event is a business fact
A Domain Event states that something meaningful already happened inside a domain:
public sealed record OrderPlaced(
Guid EventId,
Guid OrderId,
Guid CustomerId,
decimal TotalAmount,
DateTimeOffset OccurredAtUtc
) : INotification;
A useful Domain Event has four properties:
- Business meaning:
OrderPlaced, notOrderRowInserted. - Past-tense naming: the event reports a completed decision.
- Immutability: handlers cannot rewrite history.
- Sufficient context: handlers receive stable identifiers and facts needed to react.
An event should describe the smallest stable business fact, not serialize the entire aggregate. Passing a live entity reference can expose mutable state, create lazy-loading surprises, and make handlers depend on the aggregate’s internal structure.
A command is intent; an event is outcome
PlaceOrder asks the system to attempt an action. It can be rejected.
OrderPlaced says the aggregate accepted the action and changed state. It must not be named or treated like another command.
This distinction affects dispatch semantics:
- A command normally has one responsible handler.
- A Domain Event may have zero, one, or several handlers.
- A command can fail before a fact exists.
- An event handler can fail after the originating fact has already been committed.
The aggregate owns the decision
The aggregate validates invariants, changes its own state, and records the event. It does not send email or publish to Kafka.
This is a boundary rule, not a claim that every business decision starts inside one aggregate. A domain service or policy can make a decision that does not naturally belong to a single entity. A process manager or saga can coordinate a long-running decision across aggregates or services. Each aggregate still owns its own invariants and records the facts produced by its state transitions; the coordinating component owns the wider workflow.
public sealed class Order : AggregateRoot
{
private Order() { }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public static Order Place(
Guid customerId,
IReadOnlyCollection<OrderLine> lines,
TimeProvider clock)
{
if (lines.Count == 0)
throw new DomainException("An order requires at least one line.");
var order = new Order
{
Id = Guid.NewGuid(),
Status = OrderStatus.Placed,
TotalAmount = lines.Sum(line => line.Total)
};
order.Raise(new OrderPlaced(
Guid.NewGuid(),
order.Id,
customerId,
order.TotalAmount,
clock.GetUtcNow()));
return order;
}
}
The aggregate records the fact in memory. Recording is not the same as dispatching, and dispatching is not the same as durable message delivery.
Handlers belong outside the domain model
A handler translates a domain fact into an application reaction:
public sealed class WriteOrderHistory(
IOrderHistoryRepository history)
: INotificationHandler<OrderPlaced>
{
public Task Handle(
OrderPlaced notification,
CancellationToken cancellationToken)
{
return history.Append(
notification.OrderId,
"Order placed",
notification.OccurredAtUtc,
cancellationToken);
}
}
The handler can depend on repository abstractions or application ports. SMTP clients, broker producers, Redis clients, and database implementations remain in infrastructure.
Domain Events and Integration Events are different contracts
A Domain Event coordinates behavior inside one domain boundary, usually in one process. An Integration Event communicates a committed fact to another bounded context, service, or application.
They may describe the same real-world occurrence, but they have different responsibilities:
| Concern | Domain Event | Integration Event |
|---|---|---|
| Primary audience | Same domain or application | Other services or bounded contexts |
| Typical transport | In-process dispatcher | Durable broker or event stream |
| Contract ownership | Internal model | Versioned external contract |
| Delivery expectation | Defined by local transaction strategy | Usually at-least-once |
| Data shape | Domain-focused | Consumer-safe and serialization-safe |
| Failure response | Rollback, retry, or local recovery | Retry, deduplication, dead-lettering |
Do not expose the internal Domain Event class directly as a public message merely because its fields happen to match today. Translation protects both contracts from changing in lockstep.
Domain Events are not Event Sourcing events
A Domain Event communicates a meaningful fact so other behavior can react. An event-sourced aggregate uses its stored events as the authoritative history from which current state is rebuilt.
The same event can sometimes serve both roles, but that is an additional design commitment. A system can use Domain Events while persisting ordinary current state, and an event-sourced system must also decide which stored events should become internal notifications or external Integration Events. Do not assume that adding an in-memory Domain Event collection makes an aggregate event-sourced.
Internal Model
A minimal deferred event model needs an aggregate-owned collection:
public abstract class AggregateRoot
{
private readonly List<INotification> _domainEvents = [];
public Guid Id { get; protected init; }
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents;
protected void Raise(INotification domainEvent)
=> _domainEvents.Add(domainEvent);
public IReadOnlyCollection<INotification> DequeueDomainEvents()
{
var events = _domainEvents.ToArray();
_domainEvents.Clear();
return events;
}
}
The collection is private because external code must not manufacture history on behalf of the aggregate. The application can dequeue events at the unit-of-work boundary, but only aggregate behavior should add them.
When framework independence or portability is an explicit goal, keep the domain contract independent of MediatR:
public interface IDomainEvent
{
Guid EventId { get; }
DateTimeOffset OccurredAtUtc { get; }
}
An adapter can map IDomainEvent to MediatR notifications. Directly implementing INotification is simpler and is a reasonable production choice when MediatR is already an accepted dependency and the team values less adapter code. The coupling cost may be negligible in that context.
Choose deliberately among framework independence, implementation simplicity, team conventions, and long-term portability. The dependency is a trade-off, not a defect by itself.
How It Works
End-to-end flow
For an OrderPlaced event, the flow is:
- The client sends a
PlaceOrderrequest. - The application loads or creates the
Orderaggregate. - The aggregate validates invariants, changes state, and records
OrderPlaced. - The unit of work persists the aggregate.
- The application collects recorded events.
- An in-process dispatcher publishes each event to registered handlers.
- Handlers perform local reactions or prepare an Integration Event.
- Infrastructure executes I/O through explicit ports.

MediatR can implement local in-process dispatch before or after commit. It is not a broker and does not make post-commit dispatch durable. The outbox answers a different question: how to publish a required Integration Event reliably after the originating state change. A system commonly combines before-commit local dispatch with an outbox-writing handler, then relays the Integration Event after commit.
The ordering of steps 4 through 7 is a design choice, not a mechanical rule.
Local option A: dispatch before commit
Dispatch before SaveChanges when every handler writes through the same DbContext and all changes must succeed or fail together.
await dispatcher.DispatchAsync(trackedAggregates, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
This is the simplest atomic model for a relational database when one transaction can contain all affected data. Its costs are a larger transaction, tighter coupling to one persistence boundary, and a higher risk that handlers recursively raise more events.
Never perform irreversible external I/O such as sending email inside this transaction. A database rollback cannot unsend an email.
Local option B: commit before dispatch
Commit first when the original aggregate is the consistency boundary and downstream local reactions may be eventually consistent.
var events = trackedAggregates
.SelectMany(aggregate => aggregate.DequeueDomainEvents())
.ToArray();
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var domainEvent in events)
await publisher.Publish(domainEvent, cancellationToken);
This shortens the original transaction and ensures handlers observe committed state. It also creates a failure window: the process can stop after the commit and before dispatch. In-memory events are then lost.
Use this option only when lost reactions are acceptable, another recovery mechanism can reconstruct them, or the durable work is captured in an outbox.
External publishing: commit business state and outbox atomically
External publishing is complementary to the local dispatch choice, not a third mutually exclusive option. Required cross-service delivery needs a durable boundary: map selected Domain Events to Integration Events and commit the business change with an outbox record.
The Transactional Outbox article owns the relay, claiming, confirmation, retry, and retention details. The Idempotent Consumer article covers the duplicate-delivery boundary on the receiving side.
Design Guidance
Use Domain Events when
Use the pattern when at least one of these conditions holds:
- A completed aggregate decision has multiple independent reactions.
- A business rule spans aggregates and the consistency strategy is explicit.
- New reactions should be added without modifying stable aggregate behavior.
- The business fact deserves a name in the ubiquitous language.
- A local fact must be translated into a durable Integration Event.
Skip or scale it down when
A direct method call is clearer when one object owns the entire invariant and the reaction is part of that object’s immediate behavior.
A small CRUD application may not benefit from aggregate event collections, a mediator, and an outbox. If the only reaction is setting another field in the same aggregate, keep the behavior together.
Do not raise events for technical details such as EntitySaved, CacheMissed, or ControllerCompleted. Those are telemetry or infrastructure signals unless the business actually uses that language.
Make two boundary decisions deliberately
First, choose when local Domain Event handlers run:
- Must the reaction be atomic with the original aggregate?
- Can every required write share one local transaction?
- If dispatch occurs after commit, how will the system detect and recover a missed or failed reaction?
The answers lead to one local choice:
- Same transaction required and possible: dispatch local handlers before commit.
- Eventual consistency acceptable: commit, then dispatch with explicit recovery.
Then decide whether the fact must cross a process boundary:
- Is an Integration Event required, or is this only a local reaction?
- Which versioned external contract should represent the committed fact?
- Can consumers repeat the effect safely?
- How will operators detect and recover failed publication?
When cross-process delivery is required, write the Integration Event to an outbox in the original transaction and relay it after commit. This external reliability decision can be combined with either local dispatch option, although the outbox record itself must be prepared before the transaction commits.
Keep event payloads intentional
Include identifiers, the occurrence time, and stable business values needed to interpret the fact. Avoid secrets, payment credentials, large object graphs, ORM proxies, and fields added only because a hypothetical consumer might want them.
Use an event ID when handlers or consumers need deduplication. Use UTC timestamps and define whether the time means when the aggregate made the decision or when infrastructure observed it.
Treat handler ordering as undefined
Multiple MediatR notification handlers should be independent. Do not design handler B to require handler A to run first. If order is a business requirement, model an explicit workflow, command sequence, process manager, or saga.
Real-world Application
Consider placing an order with five reactions.
| Reaction | Recommended Boundary | Reason |
|---|---|---|
| Persist the Order | Original transaction | The aggregate state is the primary business record. |
| Reserve inventory | According to the required business consistency | The required consistency depends on whether temporary overselling is acceptable and whether inventory belongs to the same consistency boundary. |
| Write required audit history | Same transaction if it is part of the business record; otherwise post-commit | The business determines whether missing history is acceptable after a successful transaction. |
| Send confirmation email | After commit through durable work | SMTP is external and cannot participate in the database transaction. |
| Notify fulfillment service | Transactional outbox | Publishing to a broker is a second write across a failure boundary and requires reliable delivery. |
| Invalidate or refresh cache | After commit | Cache is derived state and should not determine business correctness. Recovery may use retry, expiration, or refresh depending on the cache strategy. |
A practical handler can translate the Domain Event into an outbox message without publishing directly:
public sealed class PrepareOrderPlacedIntegrationEvent(
IOutboxWriter outbox)
: INotificationHandler<OrderPlaced>
{
public Task Handle(
OrderPlaced domainEvent,
CancellationToken cancellationToken)
{
var message = new OrderPlacedIntegrationV1(
domainEvent.OrderId,
domainEvent.CustomerId,
domainEvent.TotalAmount,
domainEvent.OccurredAtUtc);
return outbox.Add(message, cancellationToken);
}
}
This handler must execute before the transaction that stores both the order and outbox record commits. The relay, not this handler, contacts the broker after commit.
Failure Cases
The event is dispatched before the aggregate commits
A handler sends an email, then SaveChanges fails. The customer receives confirmation for an order that does not exist.
Detection: compare external side effects with committed order IDs.
Prevention: keep irreversible I/O outside the transaction and drive it from durable post-commit work.
The process crashes after commit but before in-memory dispatch
The order exists, but no handler runs.
Detection: reconcile committed state against audit, notification, or outbox records.
Prevention: persist required reactions or Integration Events in the same transaction as the order.
The broker receives a message twice
The outbox relay publishes successfully, crashes before marking the record, and publishes again after restart.
Detection: log the event ID at publisher and consumer boundaries.
Prevention: make consumers idempotent using a processed-message record, natural business key, or compare-and-set operation.
Handlers depend on execution order
One handler expects another handler to create data first. A library upgrade, registration change, or parallel publisher breaks the hidden sequence.
Detection: run handlers independently and randomize order in a focused test.
Prevention: replace implicit ordering with one orchestrated workflow.
Events escape their aggregate without being cleared
The same tracked aggregate is saved twice and old events dispatch again.
Detection: assert the event collection is empty after a successful dequeue.
Prevention: copy and clear events atomically at the dispatch boundary.
Event chains never terminate
A handler changes an aggregate, raises another event, and eventually triggers the original event again.
Detection: record correlation ID, causation ID, event type, and dispatch depth.
Prevention: define ownership for each transition, reject cycles during design review, and impose a bounded dispatch loop.
Trade-offs
Domain Events improve separation, but they make control flow less visible in a single file. Engineers must use tracing, naming, and tests to follow the reaction graph.
They also introduce more types and operational decisions:
- event contracts;
- handler registrations;
- transaction boundaries;
- retry and idempotency policies;
- outbox storage and cleanup;
- observability across correlation and causation.
MediatR reduces dispatcher plumbing but adds a runtime registration dependency. A small explicit dispatcher can be easier to debug in a compact codebase. A broker provides durability across processes but adds latency, eventual consistency, schema evolution, and operational failure modes.
The pattern pays for itself when the business fact has several consequences or crosses a real boundary. It becomes ceremony when used for every property assignment.
Verification
Verify the implementation at four boundaries.
1. Aggregate test
Setup: create an order with valid lines and a deterministic clock.
Action: call Order.Place.
Expected observation: state becomes Placed; exactly one immutable OrderPlaced event contains the order ID, customer ID, total, event ID, and expected occurrence time.
Limit: this test does not prove dispatch or persistence.
2. Transaction test
Setup: use the real database provider in an isolated test database; register a handler that writes required local state.
Action: force the handler or SaveChanges to fail.
Expected observation: for the before-commit strategy, neither aggregate state nor handler state commits. For the after-commit strategy, the aggregate remains committed and the recovery record identifies the unfinished reaction.
Limit: an in-memory provider cannot prove relational transaction behavior.
3. Observability check
Setup: send one request with a known correlation ID.
Action: follow the request through aggregate change, Domain Event dispatch, outbox persistence, broker publish, and consumer handling.
Expected observation: logs or traces connect correlation ID, causation ID, event ID, aggregate ID, handler name, attempt count, and final result without exposing sensitive payload data.
Limit: trace continuity does not prove business correctness; combine it with state assertions.
Before review, also confirm:
- event names use business language and past tense;
- handlers do not rely on registration order;
- external I/O does not run inside a database transaction;
- required cross-process messages use a durable outbox;
- consumers tolerate duplicate delivery;
- failed outbox records have retry, alerting, and retention policies;
- event schema changes have an explicit compatibility plan.
Decision checklist
- Does the event name a completed business fact in past tense?
- Does the aggregate own the invariant and record the event without calling infrastructure?
- Must each local reaction commit atomically, or can it complete later?
- Could an after-commit reaction be lost, and is that outcome acceptable or recoverable?
- Does every required cross-process message enter a durable outbox?
- Are handler ordering, duplicate delivery, and schema evolution explicit?
Key takeaways
- Domain Events express completed decisions; they are not commands or broker messages.
- Local dispatch timing and external publication are separate boundary decisions.
- Before-commit handlers may share one local transaction but must avoid irreversible I/O.
- After-commit in-memory dispatch can lose reactions unless another durable mechanism exists.
- Integration Events need an explicit contract and durable publication path.