· 17 min read
Event Sourcing Behind the Port You Already Have
code · kisztof/ddd-hexagonal-symfony-lending @ part-5 →
Series: DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 5
The payoff for four parts of discipline is one line of YAML. The loan stops being a row that gets
overwritten and becomes an append-only stream of everything that ever happened to it, and the change
that does it is a single alias in config/services.yaml. The aggregate does not know. The handlers
do not know. The controllers do not know.
That is the claim the series has been building toward, and it is true. It is also not the whole invoice. Swapping the adapter took one line; making the swap work took a creation event the domain did not need, a timestamp parameter that rippled through eight call sites, and a synchronous projection without which every read endpoint returns nothing. I ran all of it, and I am going to show both halves.
This article covers what an event-sourced LoanRepository looks like when the port was designed
before anyone thought about event sourcing, why the unique index on (loan_id, version) replaces
the optimistic lock and raises the same domain exception, why stored event names must not be class
names, why fat events are the right call and what they freeze, what the swap forced back into the
domain anyway, and why switching the alias forward is an edit while switching it back is a
migration.

Only the plates are solid. The amber shape above them is not stored anywhere; it is what you get by reading the row in order, and it lasts as long as something is holding it.
The one line#
Here is the entire swap, in config/services.yaml:
-App\Domain\Port\LoanRepository: '@App\Infrastructure\Dbal\DbalLoanRepository'
+App\Domain\Port\LoanRepository: '@App\Infrastructure\EventSourced\EventSourcedLoanRepository'That is not a simplification for the article. It is the diff. With that line changed and the cache
cleared, POST /loans writes no row to loans as its record of truth; it appends
loan.approved.v1 at version 1 of a stream in loan_events. Disbursement appends version 2.
A repayment appends version 3, and a final repayment appends versions 3 and 4 together. Reading the
loan back means reading every row for that identifier in version order and folding them into an
aggregate that has never been stored.
The reason one line is enough is the shape established in part 1. LoanRepository lives in
Domain/Port/, it names three operations, and it says nothing about storage:
interface LoanRepository
{
public function nextIdentity(): LoanId;
public function get(LoanId $id): Loan;
public function save(Loan $loan): void;
}Two things about that interface are load-bearing here, and neither was obvious when it was written.
It has no findBy anything, so the store is never asked a question an event stream cannot answer.
And save() takes the whole aggregate rather than a delta, so an adapter is free to decide that
"save" means "write the new state" or "append what changed" without the caller caring which.
A port that had leaked one query method would have made this swap impossible. That is the actual lesson, and it was paid for four parts ago.
Four adapters, one contract test#
tests/Contract/LoanRepositoryContract.php is an abstract test case that knows only the port. Each
adapter supplies itself and inherits the same assertions. Adding the event-sourced adapter meant
writing a subclass with two methods:
final class EventSourcedLoanRepositoryTest extends LoanRepositoryContract
{
protected function setUp(): void
{
$this->connection = DriverManager::getConnection(
(new DsnParser(['postgresql' => 'pdo_pgsql']))->parse($_ENV['DATABASE_URL']),
);
$this->connection->executeStatement('TRUNCATE TABLE loan_events');
$this->connection->executeStatement('TRUNCATE TABLE loan_installments, loans');
}
protected function repository(): LoanRepository
{
return new EventSourcedLoanRepository(
new LoanEventStore($this->connection, new LoanEventSerializer()),
$this->connection,
);
}
}The thing to notice is what is absent. There is no event-sourcing-specific assertion in that file and none in the contract. The contract still says an approved loan survives a round trip, a disbursed loan keeps its schedule and outstanding balance, and saving twice updates rather than duplicates. A storage model that cannot satisfy those three sentences is not interchangeable, whatever else it does well.
Four implementations, one abstract test case, one alias deciding which the application gets. The
full suite at part-5 reports OK (59 tests, 194 assertions), and eight of those tests are the ones
this part added.
The store is one table and one index#
The migration is thirty-seven lines, and only one of them is interesting:
CREATE TABLE loan_events (
id BIGSERIAL PRIMARY KEY,
loan_id UUID NOT NULL,
version INT NOT NULL,
event_name VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMP(6) WITH TIME ZONE NOT NULL
);
CREATE UNIQUE INDEX uniq_loan_events_stream_position ON loan_events (loan_id, version);That unique index is the concurrency control. Part 2 stopped double disbursement with an optimistic
lock: a version column on loans, an UPDATE ... WHERE id = ? AND version = ?, and a
ConcurrentModification when it affected zero rows. An event store needs no version column and no
conditional update, because appending is the only write it ever does, and two appenders that both
loaded version 3 will both try to insert version 4. One of them violates the index. The whole store
is LoanEventStore:
public function append(LoanId $id, int $expectedVersion, array $events): void
{
foreach ($events as $offset => $event) {
try {
$this->connection->executeStatement(
'INSERT INTO loan_events (loan_id, version, event_name, payload, occurred_at)
VALUES (:id, :version, :event_name, :payload, :occurred_at)',
[
'id' => $id->value,
'version' => $expectedVersion + $offset + 1,
'event_name' => $this->serializer->nameOf($event),
'payload' => json_encode($this->serializer->toPayload($event), JSON_THROW_ON_ERROR),
'occurred_at' => $event->occurredAt()->format('Y-m-d H:i:s.uP'),
],
);
} catch (UniqueConstraintViolationException) {
throw ConcurrentModification::ofLoan($id);
}
}
}Two details matter more than the SQL. The exception thrown is the same ConcurrentModification the
DBAL adapter throws, which is why the HTTP layer keeps returning the same RFC 9457 problem document
with no change: the adapters differ in mechanism and agree on failure vocabulary. And the loop
inserts one row per event rather than one multi-row statement, which is slower and which I kept
anyway, because the expectedVersion + $offset + 1 arithmetic is the entire correctness argument
and I wanted it visible on one line.
tests/Concurrency/EventStreamConflictTest.php proves it with two real PostgreSQL connections, the
same way part 2 proved the optimistic lock. Two repositories load the same loan, both disburse it,
the first append wins, the second raises ConcurrentModification, and the store afterwards holds
exactly two events rather than three. The losing writer leaves nothing behind. That is stronger than
what the optimistic lock gives you, where the losing writer's UPDATE simply matched no rows and
you have to trust that it wrote nothing else on the way.
Stored names are not class names#
LoanEventSerializer maps
each event class to a string, and the string is deliberately not the class name:
private const NAMES = [
LoanApproved::class => 'loan.approved.v1',
LoanDisbursed::class => 'loan.disbursed.v1',
RepaymentRecorded::class => 'loan.repayment_recorded.v1',
LoanSettled::class => 'loan.settled.v1',
];This is the same discipline part 4 applied to the wire contract, applied to a different boundary and
for a harder reason. A message on a transport is gone in seconds; the worst a bad rename does is
poison one queue. History is not gone in seconds. History is the record, it is the only thing in the
system that cannot be regenerated from something else, and a class name in Domain/ that ends up
inside four million JSONB documents is a rename you can never do.
The .v1 suffix is not decoration either. When the payload shape has to change, loan.disbursed.v2
gets its own branch in fromPayload() and the v1 branch stays forever, upcasting old documents
into the current event object. I have not implemented that here, and I want to be precise about the
gap: this example has one version of each event and no upcasting layer, so it demonstrates where the
seam goes and not what maintaining it costs over three years.
The other half of the serializer is the part people underestimate. toPayload() and fromPayload()
are hand-written for all four events, roughly 80 lines, because the alternative is a generic
reflection-based serializer that silently changes what it writes when someone adds a property. A
generic serializer is a schema you cannot see. Hand-written mapping is the schema.
The events are fat, and that freezes arithmetic#
LoanDisbursed carries the outstanding balance and the full repayment schedule, not just the fact
that a disbursement happened. RepaymentRecorded carries both the amount and the outstanding
balance after it. That is a choice with a real trade, and I made it in the direction most examples
do not.
The thin alternative records only inputs and recomputes outcomes on replay. It produces smaller documents and it is what you want if the business rules are still moving. It also means that the day someone corrects the interest calculation, every historical loan silently changes what it says it was worth. Replay stops being a recovery mechanism and becomes a rewriting mechanism.
Fat events invert that. Folding a stream, in
EventSourcedLoanRepository,
duplicates no business logic at all:
foreach (\array_slice($events, 1) as $event) {
switch (true) {
case $event instanceof LoanDisbursed:
$status = LoanStatus::Disbursed;
$outstanding = $event->outstanding;
$disbursedAt = $event->disbursedAt;
$schedule = $event->schedule;
break;
case $event instanceof RepaymentRecorded:
$outstanding = $event->outstandingAfter;
break;
case $event instanceof LoanSettled:
$status = LoanStatus::Settled;
break;
}
}No interest is calculated. No schedule is rebuilt. The fold assigns values that the aggregate already
decided when the event was recorded, then hands them to Loan::reconstitute(), which is the same
method the DBAL adapter has used since part 1. In lending, where a schedule the borrower has already
seen is a contractual number, that is the correct direction. In a domain where the rules are still
being discovered, it is the wrong one. This is a domain-shaped decision and not a technical one, and
the earlier post on event sourcing
covers the general model behind it.
The honest half: what one line did not cover#
Now the part that does not fit the marketing.
Event sourcing needs a creation event. There is no row to read that tells you a loan exists, so the
stream has to start with something that carries the principal, the rate, the term and the borrower.
Before this part, Loan::approve() recorded nothing at all. Approval was a state transition into a
row. Now it records LoanApproved, and to record it, it needs a timestamp:
public static function approve(
LoanId $id,
BorrowerId $borrowerId,
Money $principal,
InterestRate $rate,
int $termMonths,
+ \DateTimeImmutable $at,
): self {That parameter changed ApproveLoanHandler in Application/, which now takes a
Psr\Clock\ClockInterface it did not need before, and it changed seven test call sites. Nothing
subscribes to LoanApproved. No notification is sent, no read model listens, no integration event is
derived from it. It exists so that a stream can begin.
I want to state the conclusion plainly, because the series has been making the opposite noise for
four parts. The alias is one line and the domain change is not. Hexagonal architecture bought a
real thing here: Domain/ still imports no framework, Application/ still imports no adapter,
phparkitect is still green over 74 classes, and every controller, console command, consumer and
scheduler is untouched. What it did not buy is a storage decision with zero reach into the model. A
persistence model that requires a fact the domain was not recording will make the domain record it.
There is a second, smaller ripple. releaseEvents() drains, and the handler drains it after
save(), so an adapter that needs to read those events during save() cannot use it. The aggregate
gained a non-draining reader:
/** @return list<DomainEvent> */
public function pendingEvents(): array
{
return $this->events;
}Six lines, no behaviour change, and it is still an addition to a domain class made for the benefit of
one adapter. The version an aggregate was loaded at stays out of Domain/ entirely, held in a
\WeakMap on the adapter side exactly as part 2 held the optimistic lock version. Not everything can
be pushed out that cleanly, and I would rather show which parts could not.
The read side stops being optional#
Part 3 built loan_installments as a projection table because "every installment due on this date,
across the whole book" is a question a JSONB schedule column cannot be indexed for. Under the DBAL
adapter that table was an optimisation, and loans itself answered the other two read use cases
directly.
Under an event store, none of that works. SELECT ... WHERE status = 'disbursed' has no table to run
against, and answering it from events would mean folding every stream in the database. So the
event-sourced adapter writes both read tables on every save, in the same transaction as the append:
private function project(Loan $loan): void
{
$this->connection->executeStatement(
'INSERT INTO loans (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ...',
[/* ... */],
);
$this->connection->executeStatement('DELETE FROM loan_installments WHERE loan_id = :id', [
'id' => $loan->id->value,
]);
foreach ($loan->schedule?->installments ?? [] as $installment) {
// one INSERT per installment
}
}With that in place, every endpoint part 3 built keeps working through the alias swap, verified by
running them. GET /loans/{id} returns the schedule. GET /borrowers/{id}/portfolio returns six
loans and a total. GET /installments/due?on=2026-09-21 returns installments. None of those
controllers know the record moved.
Two costs come attached, and both are real. The projection is synchronous, so a write now costs one append plus one upsert plus thirteen installment statements rather than one conditional update, and the append is no longer the only write in the transaction. And the read tables are still writable by the DBAL adapter, which is the seam the next section is about.
CQRS was presented in part 3 as a choice you make when the read shapes diverge. Under event sourcing it stops being a choice. The write model cannot answer questions, so something else must, and that something is a projection you now own, operate and repair. Anyone weighing event sourcing should read that sentence as the price rather than as a design win.
Forward is an edit, backward is a migration#
This is the finding I did not expect to have to write, and it came out of actually running the swap in both directions.
With the alias pointed at the event-sourced adapter, a loan created earlier by the DBAL adapter is invisible to any command:
POST /loans/01a0250e-9b6c-72d6-98fd-5f42d3d5bef0/repayments
404 application/problem+json
{"type":".../loan-not-found","title":"The loan does not exist.",
"detail":"Loan 01a0250e-9b6c-72d6-98fd-5f42d3d5bef0 does not exist."}That is correct behaviour. The loan has a row and no stream, and the event-sourced adapter reads streams. It is also the reason the swap is not a toggle in any system with data in it: switching to event sourcing means backfilling a synthetic stream for every existing aggregate, and a synthetic stream is a fiction you will be reading in incident reviews for years.
Switching back is worse, because it looks like it works. Point the alias at the DBAL adapter and a loan that the event-sourced adapter created accepts writes immediately, since the projection left it a perfectly good row with a version. Those writes append nothing:
events | outstanding_minor | version
--------+-------------------+---------
3 | 95900 | 4Three events in the stream, a table claiming version 4, and a balance that no event accounts for. Nothing failed. Nothing logged. The record and the projection simply stopped agreeing, and the only way to notice is to go looking. If you take one operational thing from this part, take that: a projection that is also writable is not a projection, and the fact that it did not complain is the problem, not the consolation. The DBAL adapter needs to be unregistered, not merely un-aliased, and this example does not do that.
What this does not give you#
Four scope statements, because event sourcing attracts claims it does not support.
It does not give you an audit log. It gives you the events the aggregate chose to record, which is a strictly smaller set than what happened. Nothing here records who approved the loan, from which IP, under which policy version, or which command was rejected and why. Rejected commands record nothing at all, and in lending the rejections are often the interesting part. An audit log is a separate concern with separate retention rules, and the post on securing event-sourced financial systems covers what actually has to go around a stream that holds regulated data.
It does not give you temporal queries for free. "What was this loan worth on 12 March" is one filter on one stream and is genuinely easy. "What was the whole book worth on 12 March" is a fold over every stream in the database, and answering it in production means yet another projection, built and maintained by you.
It does not scale by default. The stream per aggregate is small here, since a twelve-month loan accumulates roughly 15 events over its life. Aggregates with long-running high-frequency streams need snapshots, and there are none in this code. The post on event stores becoming a bottleneck is about exactly the point where that stops being theoretical.
And it does not survive a deleted event. There is no UPDATE and no DELETE path in the store, by
design, which means a GDPR erasure request lands on a table whose entire premise is that rows are
immutable. Crypto-shredding is the usual answer and it is a design decision taken before the first
event is written, not after.
Cost ledger#
What the event-sourced adapter cost in this codebase, measured rather than estimated.
| Item | Cost |
|---|---|
| New production code | 3 classes, 389 lines in Infrastructure/EventSourced/ |
| New schema | 1 table, 1 unique index, 37-line migration |
| Domain changes forced by the adapter | LoanApproved (28 lines), pendingEvents() (6 lines), a sixth parameter on approve() |
| Application changes forced by the adapter | ApproveLoanHandler gains a clock and a dispatcher |
| Call sites touched by that one parameter | 8, of which 7 are tests |
| Write path per repayment | 1 append, 1 upsert, 1 delete, 12 inserts, against 1 conditional update before |
| Test code added | 2 files, 170 lines, 8 tests |
| Reversibility | Forward needs a backfill, backward needs the DBAL adapter unregistered |
| Debuggability gained | The full causal history of every loan, queryable in SQL |
| Debuggability lost | No single row to look at, and current state exists only while something holds it |
The line I keep coming back to is the eighth one. Seven test call sites changed because of one parameter, and every one of them changed for a reason that has nothing to do with lending. That is what a persistence decision looks like when it reaches the model, and it reached the model despite four parts of architecture specifically designed to stop it reaching the model.
I would still take the trade for this domain. Lending disputes are historical questions, regulators ask historical questions, and a system whose record is a mutable row answers them with a table of guesses. What I would not do is present the alias as the whole story, because someone will read "one line" and plan a quarter around it.
The architecture did its job. It made the cost visible and small instead of invisible and large. Visible and small is the most any architecture gives you, and it is worth quite a lot.
$ 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.
20 min read
Securing Event-Sourced Financial Systems
Part 4 of an event sourcing series: PII kept out of events, crypto-shredding for GDPR erasure, locked-down CQRS projections, and auditable observability.