· 19 min read
Domain Events Are Not Integration Events
code · kisztof/ddd-hexagonal-symfony-lending @ part-4 →
Series: DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 4
- 1. The Domain Model, and the Only Rule That Matters
- 2. The Application Layer, the Transaction, and a Working API
- 3. The Read Side Does Not Go Through the Aggregate
- 4. Domain Events Are Not Integration Events
A lending platform starts sending disbursement confirmations that arrive before the money moves. Not often. Roughly one in four hundred, always under load, and only ever on the retry path.
The investigation takes a week because every component behaves correctly in isolation. The aggregate is right. The transaction is right. The consumer is right. The defect lives in the seam: the event was published from inside the transaction that wrote the loan, the consumer read the database faster than the writer committed, and on the runs where the transaction later rolled back, a confirmation had already gone out for a loan that never existed. Then someone adds a second consumer in another service, and it deserialises the publisher's own Money class, so a currency rounding change in the lending context breaks a notification service nobody thought was coupled to it.
This is a composite scenario, not a single incident anyone here can point to. Both halves are common enough to be worth designing against before they happen.
This article covers both seams. It walks through what the aggregate should record and why it must not dispatch, how dispatch_after_current_bus moves the release outside the transaction and how to prove it did, why the object that crosses a transport is a different class from the one the domain raised, what transport routing and a retry strategy actually promise, how a worker and a scheduler are both driving adapters wearing different clothes, and what a long-running consumer does to a database connection that was designed for a request.

The token on the rail is not the object inside the housing. It is a copy, poorer on purpose, and the mechanism it is heading for has never seen the core. The one in the tray fell out three attempts ago and is still there.
Two objects, one word#
"Domain event" and "integration event" get used interchangeably, and the conflation is where the coupling comes from. They answer different questions.
A domain event is a statement about the model, expressed in the model's own vocabulary. LoanDisbursed carries a LoanId, a Money, a RepaymentSchedule. It exists so that other parts of the same application can react to a business fact without the aggregate knowing who they are. It never leaves the process. That last sentence is the whole design.
An integration event is a message on a contract. It carries strings and integers, it is versioned, and it is deliberately poorer than the domain event it was built from. Its audience is code that will still be running the old version when a deploy is half done.
The distinction is not stylistic. It is a coupling decision. A domain event that goes onto a transport publishes every class it references: serialise LoanDisbursed directly and the consumer now depends on App\Domain\Shared\Money, on its rounding rules, and on its constructor signature. Rename a property in the aggregate and a service in another bounded context fails to deserialise a message that was already in the queue. The existing post on domain events covers what they are for; this part is about the boundary they must not cross.
The rule this codebase enforces: the domain event stays in the process, the integration event crosses the wire, and one explicit adapter translates between them.
The aggregate records, and nothing else#
The aggregate has no dispatcher, no bus, and no idea that events go anywhere. It appends to a list and hands the list over when asked:
public function recordRepayment(Money $amount, \DateTimeImmutable $at): void
{
// guards omitted, see part 3
$this->outstanding = $this->outstanding->subtract($amount);
$this->events[] = new RepaymentRecorded($this->id, $amount, $this->outstanding, $at);
if ($this->outstanding->isZero()) {
$this->status = LoanStatus::Settled;
$this->events[] = new LoanSettled($this->id, $at);
}
}
/** @return list<DomainEvent> */
public function releaseEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}Three details in twelve lines. The events are appended after the guards, so a rejected repayment records nothing and there is no compensating logic to write. Two events come out of one method call, in order, because settlement is a separate fact from payment and a consumer may care about one and not the other. And releaseEvents() drains, so calling it twice yields an empty array the second time, which makes double dispatch a bug that cannot happen rather than one that has to be remembered.
The alternative is an aggregate that dispatches directly, usually through a static event bus or an injected dispatcher. That version cannot be unit tested without a container, and worse, it dispatches at a moment the domain has no business choosing. The aggregate does not know whether the transaction will commit. Only the thing that owns the transaction knows that.
DomainEvent itself is one method:
interface DomainEvent
{
public function occurredAt(): \DateTimeImmutable;
}The timestamp is on the interface because it is the one thing every consumer of every event needs and the one thing that must not be recomputed later. occurredAt() is the moment the business fact happened, taken from the clock the handler was given. It is not the moment the message was published, which can be seconds later, and it is not NOW() in the consumer's database, which can be minutes later after a retry.
The dispatch happens after the commit, and that is provable#
The application handler loads, mutates, saves and dispatches:
public function __invoke(DisburseLoan $command): void
{
$loan = $this->loans->get(LoanId::fromString($command->loanId));
$loan->disburse(\DateTimeImmutable::createFromInterface($this->clock->now()));
$this->loans->save($loan);
$this->events->dispatch(...$loan->releaseEvents());
}EventDispatcher here is a driven port in Application/Port/, for the same reason the repository was one in part 1: nothing in Application/ may import Messenger. The handler is a plain object. It calls a method on an interface it owns.
That last line reads as though the events go out immediately, and they do not. The adapter, MessengerEventDispatcher, stamps them:
public function dispatch(DomainEvent ...$events): void
{
foreach ($events as $event) {
$this->eventBus->dispatch(new Envelope($event)->with(new DispatchAfterCurrentBusStamp()));
}
}The stamp is the entire mechanism, and it works because of middleware order rather than anything written here. doctrine_transaction is added to command.bus in configuration, and configured middleware runs inside the default stack, not around it. DispatchAfterCurrentBusMiddleware is part of that default stack. So the transaction middleware is the inner wrapper around the handler, and the after-current-bus middleware sits outside it. A stamped message is held in a queue while the handler runs, and released once the outer middleware regains control, which is after the transaction has committed.
That is a claim about someone else's middleware ordering, which makes it exactly the kind of claim worth checking rather than repeating. The check is a temporary line inside the event handler, asking Doctrine whether a transaction is still open when the event arrives, run twice: once with the stamp, once without.
transaction active during event handling: false # with DispatchAfterCurrentBusStamp
transaction active during event handling: true # without itTwo runs, one line of difference in the adapter, opposite answers. Without the stamp the event handler runs inside the open transaction, which is precisely the seam in the opening scenario: a consumer can be handed a fact about a row that has not been committed and may never be. With it, the write is durable before anything is told about it.
The probe was removed afterwards. It is worth writing once, in any codebase that relies on this, because middleware order is configuration and configuration drifts.
Only the contract leaves the process#
The domain event now reaches an in-process handler whose only job is translation:
#[AsMessageHandler(bus: 'event.bus')]
final readonly class PublishLoanDisbursed
{
public function __construct(private MessageBusInterface $eventBus)
{
}
public function __invoke(LoanDisbursed $event): void
{
$this->eventBus->dispatch(new LoanDisbursedV1(
$event->loanId->value,
$event->borrowerId->value,
$event->outstanding->minorUnits,
$event->outstanding->currency->value,
$event->disbursedAt->format(\DATE_ATOM),
));
}
}That constructor call decides two things, and neither is obvious. The RepaymentSchedule is not copied across, because no consumer outside this context should be reasoning about installments it cannot recalculate. And Money becomes two scalars, an integer of minor units and a currency string, so the arithmetic stays on this side of the wire.
LoanDisbursedV1 is what actually travels:
final readonly class LoanDisbursedV1
{
public function __construct(
public string $loanId,
public string $borrowerId,
public int $outstandingMinor,
public string $currency,
public string $disbursedAt,
) {
}
}The V1 is load-bearing. A breaking change publishes LoanDisbursedV2 alongside it and both are routed until every consumer has moved, which is the only version strategy that survives a rolling deploy. Renaming a property on a class that has messages in flight is not a version strategy.
Note where this class lives: Infrastructure/Messaging/Contract/. It is not domain vocabulary. It is a wire format, it belongs with the transport that carries it, and putting it in Domain/ would quietly make the domain responsible for a serialisation concern.
That the contract stays flat is worth a test, because the failure mode is silent until deploy day. Reflection over the properties, asserting every declared type is a builtin, is four lines and catches the day someone adds a Money back:
foreach ((new \ReflectionClass(LoanDisbursedV1::class))->getProperties() as $property) {
$type = $property->getType();
self::assertInstanceOf(\ReflectionNamedType::class, $type);
self::assertTrue($type->isBuiltin(), sprintf('%s must not cross a transport as an object.', $property->getName()));
}This is a structural assertion, not a behavioural one. It says nothing about whether the values are correct, which is a separate test. It says that nothing on this contract can drag a domain class onto the wire, and that is the failure it exists to catch.
Routing decides what is asynchronous, one class at a time#
The transport configuration is short, and the omissions carry as much meaning as the entries:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 3
max_delay: 60000
failed: 'doctrine://default?queue_name=failed'
failure_transport: failed
routing:
App\Infrastructure\Messaging\Contract\LoanDisbursedV1: asyncOne class is routed. Not a namespace, not an interface, one class. Anything unrouted is handled synchronously in the same process, which means the domain events stay local by default and going asynchronous is an explicit, reviewable line in a configuration file rather than a property of where a class happens to sit.
A note on the transport, since a series that ships unrun code would be worth nothing. The plan for this part named AMQP against RabbitMQ. The machine this was built on has no ext-amqp and no rabbitmq-c, and installing a PHP extension to make a blog post's transcript look better is not a trade worth making. So the DSN points at Doctrine, backed by the PostgreSQL instance that is already running, and every transcript below is real output from that transport.
The scope of that substitution: everything in this article about routing, retry, the failure transport, the consumer and the scheduler is transport-agnostic and would be identical under AMQP. What it does not cover is anything AMQP-specific. Exchange and binding topology, publisher confirms, dead-letter exchanges, prefetch and consumer acknowledgement modes are all real concerns and none of them appear here. The RabbitMQ protocols and policies post covers that layer directly.
Swapping is one environment variable:
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=default
MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messagesThe Doctrine transport creates its own table on first use, which is convenient in development and a surprise in production, where the deploying user usually cannot run DDL. Setting auto_setup=0 and writing the messenger_messages table into a normal migration puts schema changes back where the rest of them live.
The consumer is a driving adapter#
A worker consuming a queue and a controller handling a request are the same shape. Something outside calls in, carrying data from a protocol the application does not know about. Both are driving adapters. The only difference is which protocol, which is why LoanDisbursedConsumer sits in the same layer as the controllers:
#[AsMessageHandler(bus: 'event.bus')]
final readonly class LoanDisbursedConsumer
{
private const TEMPLATES = ['PLN' => 'disbursement.pl', 'EUR' => 'disbursement.en'];
public function __invoke(LoanDisbursedV1 $event): void
{
$template = self::TEMPLATES[$event->currency]
?? throw new \RuntimeException(sprintf('No disbursement template for %s.', $event->currency));
$this->connection->executeStatement(/* INSERT INTO outbound_notifications ... */);
}
}What this consumer does not have is the interesting part. No repository. No LoanId. No call back into the write model to fetch anything the message did not carry. It stands in for a service in another bounded context, and the constraint that makes it a useful stand-in is that it could not reach into this one even if it wanted to.
Running it end to end against a disbursed loan produces a row:
loan_id | template | payload
01a0252b-c633-7acf-8174-5959feae3396 | disbursement.pl | {"currency": "PLN", "borrower_id": "0197f3a0-...", "disbursed_at": "2026-08-21T16:33:40+00:00", "outstanding_minor": 106000}The 106,000 minor units is the principal of 100,000 plus 6% interest, computed by the aggregate and carried across as an integer. The consumer did no arithmetic. It could not have, since it never saw a Money.
What happens when the consumer fails#
The template lookup throws on an unmapped currency. That is a real business hole rather than a contrived one: the domain accepts GBP, the notification side has no English-for-Britain template, and the mismatch is exactly the kind that shows up between two contexts that version independently.
The lifecycle a failing message goes through:
Disbursing a GBP loan and running the worker gives the whole path in four lines:
[warning] Sending for retry #1 using 958 ms delay. Error: "No disbursement template for GBP."
[warning] Sending for retry #2 using 2896 ms delay. Error: "No disbursement template for GBP."
[warning] Sending for retry #3 using 8131 ms delay. Error: "No disbursement template for GBP."
[critical] Removing from transport after 3 retries. Error: "No disbursement template for GBP."The delays are 958 ms, 2,896 ms and 8,131 ms rather than the configured 1,000, 3,000 and 9,000, because Messenger applies jitter to the multiplier. That jitter is not decoration. It is what stops a thousand messages that failed together from retrying together.
"Removing from transport" is the line that would be alarming if the failure transport were not configured, and reassuring because it is. The message is in the failure queue, with its history:
Id Class Failed at Error
8 ...\LoanDisbursedV1 2026-08-21 16:37:38 No disbursement template for GBP.
Message history:
* Message failed at 2026-08-21 16:37:28 and was redelivered
* Message failed at 2026-08-21 16:37:30 and was redelivered
* Message failed at 2026-08-21 16:37:38 and was redelivered
Run messenger:failed:retry 8 --transport=failed to retry this message.Two properties worth naming. The message survived the process that could not handle it, so adding the missing template and running messenger:failed:retry 8 replays it rather than reconstructing it by hand. And it stopped after three attempts instead of spinning, which matters because a poison message with unlimited retries is a denial of service the system performs on itself.
Retries are also the reason handlers have to be idempotent. A retry is a redelivery of a message that may have partially succeeded, and at-least-once is the only delivery guarantee on offer here. This consumer inserts a row with no uniqueness constraint on the event, so a redelivery after a partial success would insert twice. That is a real gap in this example, deliberately left visible rather than papered over: the fix is a deduplication key derived from the message, and the post on idempotent Messenger handlers works through the full version including the outbox on the producer side.
The scheduler is the same adapter with a different trigger#
Overdue installments need detecting daily. Nothing sends a message when a payment fails to arrive, because the absence of an event is not an event, so something has to ask.
Symfony's Scheduler makes that a transport rather than a cron entry:
#[AsSchedule('overdue')]
final class OverdueSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return $this->schedule ??= new Schedule()
->with(RecurringMessage::cron('0 6 * * *', new DetectOverdueInstallments()));
}
}DetectOverdueInstallments is an empty class, and the emptiness is intentional. A RecurringMessage is constructed once when the schedule is assembled, so a date baked into the message would be the date the worker started, not the date the run happens. On a worker that has been up for nine days, that difference is nine days. The handler asks the clock instead.
The handler itself reaches for the read port from part 3, never the repository. Finding out which installments are late is a query. Loading a few thousand aggregates to answer it is the mistake the read side exists to prevent.
The proof is one command:
$ bin/console messenger:consume scheduler_overdue --limit=1 -vv
INFO [messenger] Received message ...\DetectOverdueInstallments {}
INFO [app] Installments due on 2026-08-21: 5 ["date" => "2026-08-21","count" => 5]
INFO [messenger] ...\DetectOverdueInstallments {} was handled successfully (acknowledging to transport).That transcript has a caveat. A cron trigger of 0 6 * * * means a worker started at noon sits idle until six the next morning, so the run above used a two-second interval trigger, reverted immediately afterwards. bin/console debug:scheduler confirms the committed configuration resolves to Sat, 22 Aug 2026 06:00:00 +0000, which is a statement about the schedule and not about the handler. The handler was proven separately, above.
A missed run is the scheduler's real failure mode. If no worker is running at 06:00, the trigger for that day does not fire and nothing catches up by default. Schedule::stateful() with a cache pool records the last run and replays what was missed, which is the difference between a schedule and a reminder.
What a long-running worker does to a connection#
Everything above assumes the worker keeps running. Long-lived PHP processes break assumptions that a request-response lifecycle hid.
A DBAL connection can go away underneath a worker. A database restart, a failover, or an idle timeout on the server side leaves the worker holding a socket that is closed. Symfony ships doctrine_ping_connection and doctrine_close_connection middleware for exactly this, and a worker consuming a transport backed by the same database should have them. A worker without them fails every message after the first disconnect, and the failures look like application bugs.
The entity manager accumulates. With the ORM adapter in play, the identity map grows for the lifetime of the process, because nothing clears it between messages the way the end of a request used to. doctrine_clear_entity_manager exists for this. The DBAL adapter this series uses sidesteps it entirely, which is one more small argument in its favour, not a general one.
Deployed code is whatever the worker started with. A worker is a running copy of the version it booted. Deploying without messenger:stop-workers leaves old handlers processing new messages, which is a correctness problem the moment a contract changes.
Memory is a budget, not a resource. --memory-limit and --time-limit exist because a leak in a request handler is invisible and the same leak in a worker is an outage. Restarting on a schedule is not a workaround for a leak, it is an operational floor under one.
None of this is exotic and none of it is optional. It is the cost of the asynchrony, and it is paid in operations rather than in code, which is why it tends to be discovered after the feature ships.
What the transport promises#
The honest summary of the whole mechanism, with the promises separated from the things that only look like promises.
| Property | What is actually guaranteed | What is not |
|---|---|---|
| Ordering | Per-message only, and even that is lost after the first retry pushes one message behind another | Anything global. Two events from one aggregate can be consumed out of order |
| Delivery | At least once, given a running worker and an intact transport | Exactly once. That is a property of the handler, not the queue |
| Durability | The message is in PostgreSQL before the worker sees it | That the message exists if the process died between commit and dispatch. That gap needs an outbox |
| Failure | Three retries with jitter, then the failure transport, replayable by id | That anyone looks at the failure transport. It is a queue, not an alert |
| Timing | Eventually, on a healthy worker | Any latency bound. A backlog is invisible from inside the request that produced it |
| Isolation | The consumer cannot reach the write model, structurally, because it holds only scalars | That the contract is compatible. Only versioning gives that |
| Scheduling | The cron trigger fires on a worker that is running at the time | Catch-up after a missed window, unless the schedule is stateful |
Reading the right column as a list of defects gets the trade backwards. Every entry is a property that was traded away deliberately, in exchange for a write path that commits without waiting for a notification, and a consumer that can fail without failing the disbursement. That trade is worth making when the reaction genuinely does not belong in the transaction. It is not worth making for a step that has to succeed for the business operation to be correct, which belongs inside the transaction where its failure can still roll something back.
The gap in the third row is the one that deserves the most attention, and it is the one most often waved away. The write commits, then the process is killed, and the event is gone: the loan is disbursed and nothing downstream will ever hear about it. Nothing in this article closes that hole. Closing it means writing the outgoing message into the same transaction as the state change and publishing from that table afterwards, which is the outbox pattern, and it is the honest answer to "how do I make this reliable" rather than a larger retry count. The post on idempotent Messenger handlers builds that table and the deduplication key on the consumer side, against SQS rather than this transport, and the mechanism is the same one this example is missing.
At this tag, part-4, the suite runs 51 tests and 168 assertions, up from 44 and 143. The architecture rules still pass over 71 classes, which is the only reason Domain/Event/ can be trusted to contain nothing that knows a transport exists.
References#
- Building Idempotent Message Handlers with Symfony Messenger for the deduplication and outbox half of at-least-once
- Unlocking the Power of Domain Events in DDD for what a domain event is for
- RabbitMQ: An Introduction to Message Queuing Protocols and Policies for the AMQP layer this part deliberately left out
- Symfony Messenger: dispatching messages after the current bus
- Symfony Messenger: retries and failure transport
- Symfony Scheduler
$ related posts
8 min read
Hexagonal Architecture and Domain-Driven Design, Together
Hexagonal architecture is a rule about dependency direction, not a folder layout. What ports and adapters buy you, where DDD attaches, what it costs.
21 min read
Building Idempotent Message Handlers in Symfony Messenger
A production guide to idempotent Symfony Messenger handlers using SQS, Redis locks, PostgreSQL deduplication, and the Outbox Pattern.
10 min read
The EntityManager Is Closed: Causes, Reset, and Recovery
Why Doctrine closes the EntityManager after a failed flush, what resetManager() restores and what it does not, and how to keep workers alive.