Skip to content

// Domain-Driven Design · 07

Domain Events

Aggregate changesleads toafter commitOrderSubmittedleads toHandlers react

A past-tense fact the domain cares about, raised by an aggregate.

How it fits together

Domain Events structureApplication serviceSubmitOrder use caseOrderSubmit()records OrderSubmittedOrders + Outboxorder row + outbox rowone transaction«record»OrderSubmittedOrderId, Total, Atpast tense, immutableStock handlerin-processreserves stockMessage brokerorder-submitted.v1to Billing, Shipping1 Submit()2 records3 commit4 dispatch5 outbox relay
uses / callscreatespublishes / notifies· numbered steps replay in order
  • Application service uses / calls Order (1 Submit())
  • Order creates OrderSubmitted (2 records)
  • Application service creates Orders + Outbox (3 commit)
  • Application service publishes / notifies Stock handler (4 dispatch)
  • Orders + Outbox publishes / notifies Message broker (5 outbox relay)

Spot it

  • Submit() also emails, reserves stock and bills
  • Emails sent for changes that rolled back
  • Other contexts poll tables to spot changes

It costs

The flow becomes implicit and eventually consistent, so handlers must be idempotent and traced.

In code

public sealed record OrderSubmitted(OrderId OrderId, DateTimeOffset At) : IDomainEvent;​public sealed class Order(OrderId id){    private readonly List<IDomainEvent> events = [];    public IReadOnlyList<IDomainEvent> Events => events;    public OrderStatus Status { get; private set; } = OrderStatus.Draft;​    public void Submit(DateTimeOffset now)    {        Status = OrderStatus.Submitted;        events.Add(new OrderSubmitted(id, now));    }​    public void ClearEvents() => events.Clear();}​public sealed class SubmitOrderHandler(IOrderRepository orders, IEventDispatcher dispatcher){    public async Task HandleAsync(OrderId id, CancellationToken ct)    {        var order = await orders.GetAsync(id, ct);        order.Submit(DateTimeOffset.UtcNow);        await orders.SaveAsync(order, ct); // commits state and outbox rows together        await dispatcher.DispatchAsync(order.Events, ct);        order.ClearEvents();    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

A domain event records something that happened that domain experts care about, named in the past tense in the ubiquitous language: OrderSubmitted, PaymentCaptured. The aggregate records it as part of its state change. It is a fact, so handlers react to it; they cannot veto it.

Timing matters. Handlers that change other aggregates inside the same transaction break the one-aggregate-per-transaction rule, and handlers with side effects (email, HTTP calls) must run after the commit, or a rolled-back change still sends the email. Anything that leaves the process goes through an outbox written in the same transaction, so a crash after the commit cannot lose it.

Domain events are internal to a bounded context and may carry domain types. Integration events cross context boundaries: a public, versioned contract with plain data and only what consumers need. Mapping one to the other lets you refactor inside without breaking other teams. Raise events for facts others react to, not for every setter.

Ask for it

  • domain event
  • integration event
  • past tense
  • dispatch after commit
  • transactional outbox
  • eventual consistency

Have Order.Submit() record an OrderSubmitted(orderId, total, submittedAt) domain event; after the commit, dispatch it to in-process handlers (reserve stock, send confirmation), and map it to a versioned order-submitted.v1 integration event written to the outbox in the same transaction.