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
// 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
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.
<?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.
<?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,
) {
}
}
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.
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));
}
}
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.
<?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);
}
}
}
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.
$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.
$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.
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 successfulsave(), 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.