Skip to content

Domain events

phil/ddd-bundle

Phil\DddBundle\Domain\Event\DomainEvent is the interface of a past fact inside one bounded context.

interface DomainEvent
{
    public function occurredAt(): DateTimeImmutable;
}

Declaring an event

namespace App\Ordering\Domain\Event;

use DateTimeImmutable;
use Phil\DddBundle\Domain\Event\DomainEvent;

final readonly class OrderPlaced implements DomainEvent
{
    public function __construct(
        public string $orderId,
        private DateTimeImmutable $occurredAt,
    ) {
    }

    public function occurredAt(): DateTimeImmutable
    {
        return $this->occurredAt;
    }
}

Take the time from a Psr\Clock\ClockInterface injected where the event is raised, never from new DateTimeImmutable(), so that tests control it. The bundle ships no clock interface of its own (see out-of-scope.md).

Rules

  • A domain event is not an integration event. It stays inside the context that raised it. What crosses a context boundary goes through the published contracts of the emitting context and an anti-corruption layer on the consumer side; the bundle defines neither.
  • Events notify, they do not reconstitute. State is stored as state. There is no event store and no replay: an aggregate is never rebuilt from its events.
  • An event is a name and a time; the rest is the context's. The interface asks only for occurredAt(), so that each context designs its events' payloads.

What is implemented, and what is not

PartState
DomainEvent interfaceImplemented
Recording events on an aggregate (an interface the aggregate implements)Not started
Dispatching recorded events after the Doctrine flush, inside the handler's transaction and before its commitNot started
A documented Messenger inbox configuration for the consumers of integration eventsNot started

Until the dispatch exists, an application that needs it records events on its aggregates and dispatches them itself, on a bus of its own. Two constraints are worth knowing when doing so:

  • Dispatch inside the transaction, after the flush and before the commit, so that an event is never seen for a write that rolled back.
  • Do not rely on Messenger's DispatchAfterCurrentBusStamp for this: it moves the dispatch outside the handler's transaction, and Symfony 8.2 refuses that stamp on a transport with an inbox unless the message goes through an outbox.

A recording interface will be an interface rather than a trait: a shared trait holding a private list of events would be an aggregate base class under another name.