· 15 min read

The Read Side Does Not Go Through the Aggregate

code · kisztof/ddd-hexagonal-symfony-lending @ part-3

Series: DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 3

  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

Every read model I have watched go wrong started in the same place, and it was never the place anyone expected. It started with a list.

Someone needs a screen showing a borrower's loans. The repository already loads a loan, so it grows a findAll sibling. The screen needs the next payment date, so the loop reconstitutes twelve installments per loan to read one of them. Then it needs a total, so the loop sums money objects in PHP. Six months later that endpoint is the slowest thing in the application, and every attempt to fix it makes the aggregate worse: a lazy flag here, a partial constructor there, a getInstallmentsWithoutSchedule() that exists because one screen needed it. The model that was built to protect invariants is now shaped by a table view.

This is a composite, not one project I can point to. The shape is common enough that I now treat the first findAll on a repository as a design smell rather than a convenience.

This article covers the split that fixes it. I'll walk through why loading an aggregate to render a list is structurally wrong rather than merely slow, two fixes that look reasonable and are not, where the read port belongs and why it is not next to the write one, how to get a return value out of a Messenger bus built for fire-and-forget, and the rule I use for when a projection table earns its keep instead of one more query against the write schema.

A machined amber core fed by a sealed steel channel, next to a separate glass plate engraved with a flattened schematic of the core's interior

The channel on the left is the only way in, and it is deliberately narrow. The plate on the right is not attached to anything.

Loading an aggregate to render a list is a category error#

The performance argument against findAll is real and it is the weakest argument available. A borrower with 4 loans and 12 installments each costs 48 value objects, a schedule decode per loan, and a Money allocation per installment, to render a table with 4 rows and one total. That is wasteful. Waste can be optimised.

The structural argument is the one that matters. An aggregate is a consistency boundary. Everything about its shape follows from one question: what does this object need in memory to refuse an illegal change? Loan carries a full RepaymentSchedule because recordRepayment() has to know the outstanding balance is real and the schedule agrees with it. A list screen refuses nothing. It has no invariant to protect, so every constraint the aggregate imposes on itself is pure cost when a screen borrows it.

Two objects with different reasons to exist are being served by one class. That is the actual defect, and no amount of lazy loading fixes it, because the fix is always a compromise inside the write model on behalf of a reader.

Naming the split does not require the full ceremony. CQRS is often sold as a package: two databases, an event bus between them, eventual consistency as a fact of life. Strip that back and the useful core is much smaller. Separate the model you write through from the model you read through. Everything else in the package is optional, and most of it is not worth buying yet. That framing is the one this part builds against, and the existing CQRS post covers the pattern's own vocabulary, so I won't restate it here.

Wrong fix one: make the aggregate cheaper to load#

The first instinct is to keep one model and make it lighter. In Doctrine terms that means lazy collections, partial objects, a Loan that can exist without its schedule until something asks.

I have written this and I would not write it again. It produces an aggregate with two truths: the loaded one and the half-loaded one. Every method then has to be correct under both, and recordRepayment() cannot be, because deciding whether a repayment exceeds the outstanding balance requires the state you just declined to load. So the guard either triggers the load anyway, which buys nothing, or it silently trusts a field that may not reflect the schedule.

The failure is not a slow endpoint. The failure is an aggregate that can be constructed in a state where its own invariant is unenforceable. That is worse than the problem it was fixing, and it is invisible until the day a partially-loaded loan reaches a write path.

Wrong fix two: keep it in SQL, but keep it in the repository#

The second instinct is better and gets further. Skip the aggregate for reads, write real SQL, but leave it on LoanRepository as a second kind of method.

interface LoanRepository
{
    public function get(LoanId $id): Loan;
    public function save(Loan $loan): void;

    // BAD: the port now returns rows, so the domain has an opinion about screens.
    public function portfolioRowsFor(BorrowerId $borrower): array;
}

The port is owned by the domain, and the domain now declares a need it does not have. Nothing inside Domain/ calls portfolioRowsFor(). Nothing ever will. It sits in the domain's vocabulary because it was convenient to put it there, and the next four read methods will follow it, each shaped by a screen.

There is a second cost that shows up later. The in-memory and ORM adapters from part 1 all implement this port, so every read method has to be implemented three times, in three ways, for adapters that were only ever meant to prove the write contract. The contract test grows assertions about presentation.

The instinct is right and the address is wrong. The reads should be SQL. They should not be on the write port.

The split: a read port the application owns#

LoanRepository lives in Domain/Port/ because the domain uses it. LoanReadModel lives in Application/Port/ because the domain never will:

namespace App\Application\Port;

interface LoanReadModel
{
    public function loan(string $loanId): ?LoanView;

    public function portfolio(string $borrowerId): BorrowerPortfolio;

    /** @return list<DueInstallment> */
    public function installmentsDueOn(string $date, int $limit): array;
}

Three details in that interface are deliberate. The parameters are strings rather than LoanId and BorrowerId, because a read model is reached from a URL and validated at the boundary, not handed a domain identifier by another aggregate. The return types are view objects, never Loan. And loan() returns null rather than throwing, because "not found" is an ordinary answer to a question, not a violated rule.

The view objects carry presentation types, which is the part people skip:

final readonly class PortfolioLine
{
    public function __construct(
        public string $loanId,
        public string $status,
        public int $outstandingMinor,
        public string $currency,
        public ?string $disbursedAt,
        public ?string $nextDueOn,
        public ?int $nextAmountMinor,
    ) {
    }
}

There is no Money here and no \DateTimeImmutable, and that is not laziness. Money exists to make illegal arithmetic impossible, and a read DTO performs no arithmetic. \DateTimeImmutable exists to make date manipulation safe, and nothing here manipulates a date. Both would need unwrapping before they could be serialised, so their only effect on this class is to make json_encode produce something you have to write a normalizer for. A read model that carries domain types is a read model that will eventually be tempted to use them.

The whole rule fits in one line: nothing behind this port may return an aggregate. It is stated in a docblock on the interface, and it is the only comment in the file.

Getting a value back out of Messenger#

Part 2 declared three buses and only used one. The query bus is where Messenger stops being a natural fit, and pretending otherwise is how people end up surprised in production.

Messenger is built to dispatch and forget. dispatch() returns an Envelope, not a result, because a message might be handled now, or in a worker in 40 seconds, or by three handlers at once. A query is the opposite: exactly one handler, synchronously, and the caller wants the answer. The framework's own answer is a small wrapper, QueryBus:

namespace App\Infrastructure\Messenger;

use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;

final class QueryBus
{
    use HandleTrait;

    public function __construct(MessageBusInterface $queryBus)
    {
        $this->messageBus = $queryBus;
    }

    public function ask(object $query): mixed
    {
        return $this->handle($query);
    }
}

Two things in there are load-bearing. The constructor parameter must be named $queryBus, because that name is what makes Symfony's autowiring inject the query.bus service rather than the default one, and getting it wrong routes every query through the command bus and its transaction middleware. And HandleTrait is worth the dependency over reading the stamp yourself: dispatch() followed by $envelope->last(HandledStamp::class)?->getResult() returns null when a query has no handler at all, which reads as an empty result and fails somewhere else entirely, while handle() throws on zero handlers and on more than one.

The bus itself takes no transaction and no transport:

# config/packages/messenger.yaml
buses:
    command.bus:
        middleware:
            - doctrine_transaction

    query.bus: ~

That is the whole configuration difference, and it is doing two jobs. No transport means a query can never be routed to a worker, which would make handle() throw instead of quietly returning nothing. No doctrine_transaction means a read never opens a write transaction it does not need.

Whether the bus earns its place here is a fair question, and my answer is a qualified yes. A query bus buys uniform middleware across every read, which is where request logging and a future permission check want to live. It costs you an indirection that a stack trace has to walk through, and for three read use cases that is close to a wash. I would not add it to an application that had no bus already.

What the write schema can already answer#

The default assumption in CQRS material is that the read side reads a projection: a second table, kept current by events, holding a flattened copy of what the screens need. That is a real technique and it is not the starting point. The starting point is a purpose-built query against the tables you already have.

projected from

loans

uuid

id

PK

uuid

borrower_id

indexed

bigint

principal_minor

bigint

outstanding_minor

varchar

status

timestamptz

disbursed_at

jsonb

schedule

no useful index

int

version

loan_installments

uuid

loan_id

PK

int

number

PK

uuid

borrower_id

date

due_on

indexed

bigint

amount_minor

varchar

currency

The portfolio screen filters by borrower_id, which is indexed, and needs one column the aggregate computes: the next installment. PostgreSQL will do that without loading anything, and the query lives in DbalLoanReadModel:

SELECT l.id, l.status, l.outstanding_minor, l.outstanding_currency, l.disbursed_at,
       next_due.due_on, next_due.amount_minor
FROM loans l
LEFT JOIN LATERAL (
    SELECT (installment->>'due_on') AS due_on,
           (installment->>'amount_minor')::bigint AS amount_minor
    FROM jsonb_array_elements(COALESCE(l.schedule, '[]'::jsonb)) AS installment
    ORDER BY installment->>'due_on'
    LIMIT 1
) next_due ON TRUE
WHERE l.borrower_id = :borrower
ORDER BY l.disbursed_at NULLS LAST, l.id

The LEFT JOIN LATERAL is the part worth understanding, and the LEFT is not decoration. jsonb_array_elements unnests the schedule of each loan the outer query already selected, and the lateral join lets the subquery reference l.schedule from the row it is attached to. Written as a plain join it would produce no row at all for an approved loan, whose schedule is null until disbursement, and the borrower's newest loan would vanish from their own portfolio. That is the kind of bug a projection would not have saved you from either.

The cost of this query is bounded by how many loans one borrower has, and in consumer lending that number stays small enough that the unnesting never becomes the expensive part. What matters is that it is not bounded by the size of the table. That property decides everything below.

The one query that earned a projection#

Change the question from "this borrower's loans" to "every installment falling due tomorrow" and the same schema stops working. There is no borrower to filter by. Every row in loans has to be visited, every schedule unnested, and no index on a JSONB document helps, because the dates are inside the document rather than in a column. That query degrades linearly with the size of the book, and it is exactly the query a collections process runs every morning.

So one projection exists, and only one:

private function projectInstallments(Loan $loan): void
{
    $this->connection->executeStatement(
        'DELETE FROM loan_installments WHERE loan_id = :id',
        ['id' => $loan->id->value],
    );

    foreach ($loan->schedule?->installments ?? [] as $installment) {
        $this->connection->executeStatement(
            <<<'SQL'
                INSERT INTO loan_installments (loan_id, number, borrower_id, due_on, amount_minor, currency)
                VALUES (:loan_id, :number, :borrower_id, :due_on, :amount_minor, :currency)
                SQL,
            [
                'loan_id' => $loan->id->value,
                'number' => $installment->number,
                'borrower_id' => $loan->borrowerId->value,
                'due_on' => $installment->dueOn->format('Y-m-d'),
                'amount_minor' => $installment->amount->minorUnits,
                'currency' => $installment->amount->currency->value,
            ],
        );
    }
}

Delete then insert, rather than a careful diff, because a schedule has 12 rows and correctness is worth more than 12 saved statements. The method is called from DbalLoanRepository::save(), which means it runs inside the transaction the command bus opened, which means the projection is never stale by even one request.

That last property is bought, not free, and the price is on the same page as the goods. The write adapter now knows about a read table. It is one method and one comment naming why, but it is coupling, and if a second and third projection appear it becomes the wrong place for all of them. The alternative is a domain event and a projector running outside the transaction, which decouples them and pays in staleness: the read table lags the write, and every consumer has to be correct under a lag it cannot observe. With one small table written in the same transaction, that is a distributed systems problem purchased for nothing.

The rule I ended up with is short enough to apply in review. Project only what the write schema cannot answer with an index. Everything else gets a query.

Reading it back#

The endpoints are the same thin controllers as before, with a query in place of a command. This is a real transcript against the running application, after approving one loan of 1,000.00 PLN over 12 months at 600 basis points, disbursing it, and recording a 100.00 PLN repayment:

$ curl -sS localhost:8123/borrowers/$B/portfolio
{
    "borrowerId": "0197f3a0-2222-7000-8000-000000000002",
    "loanCount": 1,
    "totalOutstandingMinor": 96000,
    "currency": "PLN",
    "loans": [
        {
            "loanId": "01a0250e-9b6c-72d6-98fd-5f42d3d5bef0",
            "status": "disbursed",
            "outstandingMinor": 96000,
            "currency": "PLN",
            "disbursedAt": "2026-08-21 16:01:49",
            "nextDueOn": "2026-09-21",
            "nextAmountMinor": 8833
        }
    ]
}

$ curl -sS "localhost:8123/installments/due?on=2026-09-21"
[{"loanId":"01a0250e-...","number":1,"dueOn":"2026-09-21","amountMinor":8833,"currency":"PLN"}]

$ curl -sS "localhost:8123/installments/due"
{"type":".../problems/invalid-request","status":422,
 "violations":[{"field":"on","message":"This value should not be blank."}]}

The transcript is doing more work than it looks like. The outstanding balance is 96,000 minor units, which is 106,000 owed after interest minus the 10,000 repaid, and it agrees with the write side because it came from the same row rather than from a copy. The 8,833 is the first installment, and 8,833 times 12 is 105,996, with the missing 4 minor units in the last installment where the aggregate put them. And the third call proves the query string is validated at the boundary by #[MapQueryString] and a DTO, exactly like the request bodies in part 2, so an invalid date never reaches SQL.

At this tag, part-3, the suite runs 44 tests and 143 assertions, up from 33 and 97. Eleven of the new ones exercise the read path, six against a real PostgreSQL instance and five over HTTP.

That number has a scope. The read model is tested against PostgreSQL and through the kernel, but the write side stays in memory in the test environment, so these tests prove the read path and the projection, not the two of them under concurrent writers. The projection inherits whatever guarantee the transaction gives it, which is the optimistic lock from part 2 and nothing stronger.

When this is not worth it#

The split earns its place in this codebase. It does not earn its place everywhere, and the honest version of this article names where.

Skip it when the aggregate is the screen. Plenty of entities are read exactly as they are written: a settings record, a reference table, a configuration row. A separate read model there is two classes describing the same seven fields, kept in sync by hand. Write the query against the write model and move on.

Skip it when there is no invariant to protect. The whole argument above rests on the aggregate having rules that make it expensive to load. A CRUD subdomain has no such rules, so there is nothing to protect the reader from and nothing to protect from the reader.

Skip the projection, not the split, when the write schema answers the query. This is the mistake I see most often in code that is otherwise well built: a projection table introduced because the pattern says so, then a rebuild script nobody has run, then a bug six months later where the projection and the source disagree and it takes a day to work out which one is lying. Two of the three read paths in this example have no projection, and that is not a shortcut.

Skip the query bus when you have no other bus. Handlers are classes. Injecting GetBorrowerPortfolioHandler into a controller and calling it costs one line, debugs better, and is exactly as correct. The bus buys uniform middleware, and if you have no middleware yet, it buys nothing.

What I would not skip in any of those cases is the direction rule, because it survives the argument about whether the rest of this is worth it. Whatever answers a read may not return an aggregate. Whatever the domain owns may not grow a method the domain never calls. Neither needs a tool to enforce. Both fit in a review comment, and both are still true on the day someone decides the rest of this was over-engineering.

References#

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