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

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
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
· 19 min
What This Architecture Cost, Measured Rather Than ArguedNEW
What hexagonal architecture costs in Symfony 8: ports, layers and buses counted from the repository, and the thresholds below which it is not worth it. Part 6.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 6#ddd#hexagonal-architecture#symfony#php#software-architecture#cqrs#refactoring19 min
· 17 min
Event Sourcing Behind the Port You Already Have
Event sourcing in Symfony 8: an event-sourced repository swapped in by one DI alias, proven by the same contract test, and what the alias did not cover. Part 5.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 5#ddd#hexagonal-architecture#symfony#php#event-sourcing#postgresql#cqrs17 min
· 19 min
Domain Events Are Not Integration Events
Domain events vs integration events in Symfony 8: recorded by the aggregate, released after commit, versioned into a contract, consumed by a worker. Part 4.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 4#ddd#hexagonal-architecture#symfony#php#messenger#rabbitmq#domain-events19 min