PHP · Domain-Driven Design

Recorded, Not Injected

A domain event is a claim about something that happened. Publish it the moment an aggregate method runs, and you're making that claim before you know whether it's true. The fix isn't cleverer wiring, it's refusing to publish anything until persistence has already confirmed the claim.

The last article modeled a hotel booking aggregate and stopped short of one thing: when Booking::cancel() runs and adds a cancellation penalty, nothing tells the rest of the system it happened. No email, no ledger update, nothing. That's usually the point where someone reaches for a domain event, and where a specific, easy mistake shows up.

The easy mistake is injecting a publisher straight into the aggregate and calling publish() as part of the method that changes state. It reads clean. It's also wrong, for a reason that only shows up once persistence is allowed to fail.

What publishing too early actually breaks

CancelBooking.php · the version to avoid
// Booking.php, with a publisher injected
public function __construct(
    private EventPublisher $events,
    public readonly BookingId $id,
    // ...
) {
}

public function cancel(\DateTimeImmutable $now): void
{
    // ... penalty logic ...

    $this->events->publish(new BookingCancelled($this->id, $now));
    $this->status = BookingStatus::Cancelled;
}

// the use case
$booking->cancel($now);
$this->bookings->save($booking); // <-- if this throws, the event already went out
By the time save() throws, on a constraint violation, a lost connection, anything, an email has potentially already gone out announcing a cancellation that was never actually persisted. The event doesn't know the database disagreed with it. It already told the truth as it understood it, one statement too early.

The fix isn't a better publisher. It's refusing to let the aggregate publish anything at all. It can only record that something happened, in memory, and something else decides later whether that record is safe to announce.

An aggregate that records, not publishes

Every aggregate gets this from one small base class, written once.

AggregateRoot doesn't know what an event means. It just remembers that one happened.

AggregateRoot.php
<?php

declare(strict_types=1);

namespace App\Booking\Domain;

abstract class AggregateRoot
{
    /** @var object[] */
    private array $recordedEvents = [];

    protected function recordThat(object $event): void
    {
        $this->recordedEvents[] = $event;
    }

    /** @return object[] */
    public function pullEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];

        return $events;
    }
}
pullEvents() both returns and clears the list in the same call. Call it twice and the second call gets nothing, on purpose: an event that's already been pulled has already been handed off, recording it again would mean announcing the same fact twice.

The events themselves are just facts. No behavior, no equals(), nothing left to argue with.

BookingCancelled.php · CancellationPenaltyCharged.php
<?php

declare(strict_types=1);

namespace App\Booking\Domain\Event;

use App\Booking\Domain\BookingId;
use App\Booking\Domain\ChargeId;
use App\Booking\Domain\Money;

final readonly class BookingCancelled
{
    public function __construct(
        public BookingId $bookingId,
        public \DateTimeImmutable $cancelledAt,
    ) {
    }
}

final readonly class CancellationPenaltyCharged
{
    public function __construct(
        public BookingId $bookingId,
        public ChargeId $chargeId,
        public Money $amount,
    ) {
    }
}
These look like the Value Objects from the last article, and they are, minus equals(). A Value Object usually needs one because you might ask "is this the same amount as that one." Nobody asks that about an event. It happened once; comparing two BookingCancelled instances for equality isn't a question this domain ever needs to answer.

Booking extends AggregateRoot and calls recordThat() at the exact point each event becomes true.

Booking.php · updated
final class Booking extends AggregateRoot
{
    /** @var Charge[] */
    private array $charges = [];

    private function __construct(
        public readonly BookingId $id,
        public readonly DateRange $stay,
        private BookingStatus $status,
    ) {
    }

    // ... request(), confirm(), voidCharge(), total(), charges() unchanged ...

    public function cancel(\DateTimeImmutable $now): void
    {
        if ($this->status === BookingStatus::Cancelled) {
            return;
        }

        $hoursUntilCheckIn = ($this->stay->checkIn->getTimestamp() - $now->getTimestamp()) / 3600;

        if ($hoursUntilCheckIn < 48) {
            $penalty = new Charge(
                ChargeId::generate(),
                ChargeType::CancellationPenalty,
                $this->roomRateCharge()->amount->percentage(50),
                'Late cancellation penalty (less than 48h before check-in)',
            );
            $this->charges[] = $penalty;

            $this->recordThat(new CancellationPenaltyCharged($this->id, $penalty->id, $penalty->amount));
        }

        $this->status = BookingStatus::Cancelled;

        $this->recordThat(new BookingCancelled($this->id, $now));
    }
}
Two recordThat() calls, in the order the facts became true: the penalty first, since it's conditional, the cancellation itself always. Nothing here talks to Symfony, Messenger, or a mailbox. This method still only knows about its own domain, exactly like every other method in this class.

Where the truth actually gets told

The use case is the only place that knows both things at once: that save() succeeded, and what the aggregate recorded while getting there.

CancelBooking.php
<?php

declare(strict_types=1);

namespace App\Booking\Application;

use App\Booking\Domain\BookingRepository;
use Symfony\Component\Messenger\MessageBusInterface;

final readonly class CancelBooking
{
    public function __construct(
        private BookingRepository $bookings,
        private MessageBusInterface $eventBus,
    ) {
    }

    public function __invoke(CancelBookingCommand $command): void
    {
        $booking = $this->bookings->get($command->bookingId);

        $booking->cancel($command->now);

        $this->bookings->save($booking);

        foreach ($booking->pullEvents() as $event) {
            $this->eventBus->dispatch($event);
        }
    }
}
This is the only file in the whole example that imports anything from Symfony. Booking, AggregateRoot, the events themselves, none of them know Messenger exists. That's the actual point of the exercise, not "use this specific pattern" but "keep the domain from ever needing to know how the news gets delivered." It's the same boundary that matters on any API built on Symfony: the framework's bus dispatches the event, it never gets to decide whether the event happened.

Where record-and-pull still has sharp edges

Moving the decision to the use case removes one bug and opens the door to three smaller ones, all versions of the same failure: someone has to remember to do the right thing, and nothing stops them from not doing it.

1 · forget to call pullEvents(), and nothing tells you
$booking->cancel($command->now);
$this->bookings->save($booking);
// no pullEvents() call here

// the booking is cancelled in the database.
// nobody outside this process will ever know.

No exception, no warning, no failing test unless one was written specifically to check for it. The cancellation is fully, correctly persisted. It's the silence afterward that's broken, and silence doesn't page anyone.

2 · pull before save, and it's the same lie again
$booking->cancel($command->now);

foreach ($booking->pullEvents() as $event) {
    $this->eventBus->dispatch($event);
}

$this->bookings->save($booking); // too late, the events are already gone

Record-and-pull doesn't protect anything by itself. It only works if the pull happens strictly after a successful save(). Get the two lines backwards and this is exactly as unsafe as injecting a publisher into the aggregate, just with an extra step in between.

3 · pulling inside the repository looks convenient, and isn't

Folding pullEvents() into BookingRepository::save() itself removes the risk of forgetting it in the use case, which is exactly why it's tempting. It also means the repository, whose only job is turning an aggregate into rows, now decides when it's safe to tell the rest of the application what happened. Swap in a second repository implementation later, an in-memory one for tests, a cached one, anything that doesn't happen to call the same code path, and event dispatch quietly stops happening with it. The decision belongs in the use case because the use case is the only place guaranteed to run once, in order, every time.

Does this need an outbox?

Everything above assumes CancelBooking's dispatch loop runs synchronously, in the same process, in the same request that called save(). That's also how domain events get handled in most systems I've worked on: synchronously, in-process, for any listener that stays inside the same bounded context.

There's no broker between save() and the foreach loop that dispatches events. A listener that throws bubbles up in the same request, visibly, immediately. It's a bug to fix, not a silent loss to go hunting for later. The "what if the process dies between save() and dispatch()" worry this kind of code invites doesn't actually call for a stronger guarantee than what's already here, for the same reason nobody demands a two-phase commit between any two consecutive lines of PHP: the risk is real, but it isn't specific to this pattern, and paying for durability against it buys nothing a retry and an alert don't already cover.

That changes the moment a listener's job is to put the event on a queue, for another bounded context to consume, or for an async worker even inside this one. That publish is a real network call that can fail independently of the database commit that already succeeded. A crash, a timeout, a broker that's down for thirty seconds: the booking is cancelled in the database and nobody downstream ever finds out, and there's no exception anywhere in this process to notice it happened. That's the specific failure the transactional outbox pattern solves: write the event to a table in the same transaction as the aggregate, and let a separate poller publish it from there, so publishing can be retried until it succeeds instead of being lost the moment the process dies at the wrong line.

Retried until it succeeds means at least once, not exactly once. The poller doesn't know whether a prior attempt's publish actually reached the broker or just failed to acknowledge, so it will occasionally send the same event twice. Vernon is explicit about this trade-off in Implementing Domain-Driven Design: an outbox buys durability, not deduplication, so whatever consumes BookingCancelled on the other side has to treat a repeat delivery as a no-op, usually by checking an event id it's already seen. Skip that and the outbox trades a silent loss for an occasional silent double charge, which is worse.

In Symfony terms, that split is a routing decision in messenger.yaml, not something CancelBooking itself should know about. Route BookingCancelled to a synchronous handler and it runs exactly like every other listener in this article, in-process, in the same call. Route the same message class to a transport instead and it's now crossing a hop that can fail on its own, and needs the durability story pullEvents() alone doesn't provide. The use case doesn't have to decide which one it's doing. It only has to keep making the same claim: cancel, save, then hand off. What that hand-off costs to make reliable is a routing and infrastructure question, not a domain one, and gotchas 1 through 3 above apply exactly the same way whether the transport behind them is synchronous or not.

What this actually buys

None of these three gotchas are solved by a framework feature. They're solved by keeping the rule in exactly one place, the use case, and never giving anything else a way to shortcut it. The upside shows up in testing: asserting cancel() recorded the right events needs nothing but the aggregate itself.

$booking->cancel($now);

$events = $booking->pullEvents();

self::assertCount(2, $events);
self::assertInstanceOf(CancellationPenaltyCharged::class, $events[0]);
self::assertInstanceOf(BookingCancelled::class, $events[1]);

No publisher to fake, no bus to mock, no Symfony container in sight. The aggregate's behavior and the events it produces are tested as one plain PHP object, which is what keeping Messenger out of the domain was for in the first place.

A domain event is a claim, not a broadcast. recordThat() lets the aggregate make the claim. Only a successful save(), in the use case, earns it the right to tell anyone else.

This is also, not by accident, another version of the same lesson as the last article: the safe-looking version and the actually-safe version differ by one detail that's easy to get backwards, and nothing in the language stops you from getting it backwards. readonly needed equals() written down next to it. Record-and-pull needs the order of two lines respected, every single time, by every developer who ever touches this use case.

If keeping a domain this clean of its own Symfony plumbing is the part you'd rather not build from scratch, that's close to a day-one conversation on most code audits I run.

💬 Working on something similar?

Comments

← Back to Engineering Notes