~/blog/unlocking-the-power-of-domain-events-in-ddd-ddd-series

Domain Events in DDD (DDD series part 10)

Part 10 of the DDD series: what a domain event actually records, why it is named in the past tense, and the delivery guarantee it does not give you for free.

Krzysztof Słomka5 min readupdated

series · Domain-Driven Design (DDD) Series10 / 11

  1. 01What Domain-Driven Design Is (DDD series part 1)
  2. 02Benefits of using DDD (DDD series part 2)
  3. 03Who should consider using DDD? (DDD series part 3)
  4. 04Domain Model (DDD series part 4)
  5. 05Bounded Context (DDD series part 5)
  6. 06Value object (DDD series part 6)
  7. 07Entity (DDD series part 7)
  8. 08Services (DDD series part 8)
  9. 09Mastering Transactions: The Power of Aggregates in DDD
  10. 10Domain Events in DDD (DDD series part 10)
  11. 11Alternatives to Domain-Driven Design, and When to Use Them

next in series →

Domain-Driven Design series: Domain Events propagating state changes across a system

Photo by Tiffany Tertipes on Unsplash

Aggregates protect invariants inside one transaction boundary. A domain event is how something that just happened inside that boundary gets told to the rest of the system without the aggregate reaching out and calling other aggregates directly. It is one of the smaller patterns in this series and one of the most frequently misimplemented, usually by skipping the one step that makes it reliable.

This part covers what actually qualifies as a domain event, why the past-tense naming convention is a correctness signal and not a style preference, how an event differs from a command, the mechanical risk in publishing events from inside a request handler, and the guarantee that risk is actually about: whether the event is published at all, not how fast a subscriber reacts to it.

A domain event is a fact, not a request#

OrderPlaced is a domain event. PlaceOrder is a command. The difference is not cosmetic. A command is a request that something should happen and can be rejected: insufficient stock, an invalid address, a declined payment. An event is a record that something already happened and is irrevocable: the order is placed, the stock is reserved, the fact is in the past tense because it is in the past.

// A command can fail. The handler decides whether PlaceOrder succeeds.
final class PlaceOrder
{
    public function __construct(
        public readonly string $customerId,
        public readonly array $lineItems,
    ) {}
}

// An event cannot fail. By the time it exists, the order is already placed.
final class OrderPlaced
{
    public function __construct(
        public readonly string $orderId,
        public readonly \DateTimeImmutable $placedAt,
    ) {}
}

Naming an event in the future or present tense, PlaceOrder published as if it were an event, or OrderPlacing, is usually a sign that something that should be a command got modelled as an event. The tense is doing real work: it tells every subscriber that the fact is settled and there is nothing left to approve.

What the event is for#

An event lets a part of the system that cares about "an order was placed" react without the order aggregate knowing who those parts are. Sending a confirmation email, updating a read model for the order list, decrementing a stock projection: none of that is Order's job, and wiring Order directly to the mail service and the read model and the inventory projector would couple one aggregate's write path to every downstream consumer that might ever care about it.

final class Order
{
    private array $recordedEvents = [];

    public function place(): void
    {
        $this->status = OrderStatus::Placed;
        $this->recordedEvents[] = new OrderPlaced($this->id, new \DateTimeImmutable());
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

The aggregate records the event and hands it off. It does not know, and should not know, whether zero subscribers or twenty are listening for OrderPlaced. That decoupling is the entire value of the pattern, and it is also where the implementation detail that actually matters gets skipped.

The part the three-sentence version skips#

Firing an event is one line of code. Making sure it survives is not, and this is the part generic introductions to the pattern tend to leave out.

// BAD: the write and the publish are two separate operations with no shared
// transaction. If the process crashes, or the broker is unreachable, between
// the commit and the publish, the order exists and nobody downstream ever
// finds out.
$this->orders->save($order);
$this->eventBus->publish($order->releaseEvents());
// GOOD: the event row is written in the same database transaction as the
// aggregate. A separate relay process reads unpublished rows and delivers
// them, retrying on failure. The write and the record of "this needs
// publishing" either both commit or neither does.
$this->db->transaction(function () use ($order) {
    $this->orders->save($order);
    foreach ($order->releaseEvents() as $event) {
        $this->outbox->record($event);
    }
});

This is the Outbox Pattern, and it is the difference between "the event is published" and "the event is published unless the process dies at an inconvenient moment." To be precise about what this guarantees: it makes publishing at-least-once with respect to the aggregate's own write, which means a subscriber may see OrderPlaced more than once and must be able to handle that. It does not make the subscriber's own side effect (the email actually sending, the read model actually updating) exactly-once. That guarantee, if you need it, is a property of the subscriber, not of the outbox.

Keeping the event vocabulary honest#

Two habits keep this pattern from rotting. First, raise events for state changes a domain expert would recognise and care about, OrderPlaced, PolicyLapsed, not every setter call, or the event stream becomes noise nobody can usefully subscribe to. Second, treat the event's shape as a public contract the moment a second subscriber depends on it: changing a field name on OrderPlaced now breaks every consumer silently, the same way changing a public API's response shape would.

Previous parts#

Mastering Transactions: The Power of Aggregates in DDD

Services (DDD series part 8)

Entity (DDD series part 7)

Value object (DDD series part 6)

Bounded Context (DDD series part 5)

Domain Model (DDD series part 4)

Who should consider using DDD? (DDD series part 3)

Benefits of using DDD (DDD series part 2)

Unlocking the Power of Domain-Driven Design in Software Development (DDD series part 1)

$ related posts