· 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

  1. 1. The Domain Model, and the Only Rule That Matters
  2. 2. The Application Layer, the Transaction, and a Working API
  3. 3. The Read Side Does Not Go Through the Aggregate
  4. 4. Domain Events Are Not Integration Events
  5. 5. Event Sourcing Behind the Port You Already Have

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.

A long row of upright slate-blue plates on a rail, the leftmost plain and each one to its right cut with more notches than the last, with a translucent amber faceted solid hovering above the right-hand end, assembling out of the plates below it

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.

«interface»

LoanRepository

+nextIdentity() : LoanId

+get(LoanId) : Loan

+save(Loan) : void

InMemoryLoanRepository

DbalLoanRepository

OrmLoanRepository

EventSourcedLoanRepository

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 |       4

Three 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.

ItemCost
New production code3 classes, 389 lines in Infrastructure/EventSourced/
New schema1 table, 1 unique index, 37-line migration
Domain changes forced by the adapterLoanApproved (28 lines), pendingEvents() (6 lines), a sixth parameter on approve()
Application changes forced by the adapterApproveLoanHandler gains a clock and a dispatcher
Call sites touched by that one parameter8, of which 7 are tests
Write path per repayment1 append, 1 upsert, 1 delete, 12 inserts, against 1 conditional update before
Test code added2 files, 170 lines, 8 tests
ReversibilityForward needs a backfill, backward needs the DBAL adapter unregistered
Debuggability gainedThe full causal history of every loan, queryable in SQL
Debuggability lostNo 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.

author

Krzysztof Słomka

Senior Backend Engineer & Software Architect. Writing about backend architecture, DDD, Event Sourcing, distributed systems and AI engineering.

linkedin · github

$ related posts