Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-read-side-the-layers-lost && read --section="top" 0%
Architecture

The Read Side the Layers Lost

Wallets abstracts its queries behind a storage interface that returns aggregates and maps in memory. Payments injects the DbContext and projects in SQL. On the read path the layered module is the slower and more coupled one.

By Kumar Chandrachooda 13 Jan 2026 7 min read
One query that fetches everything, one that fetches what it needs

CQRS is usually sold as a write-side idea — commands, aggregates, invariants — and then the read side is left to whatever the team already does. That is a shame, because the read side is where the design choice has teeth. A query has no invariants to protect, so every abstraction you put in front of it is pure cost, paid on every page load, in exchange for a substitutability you may never use.

Part 9 deferred one of the three claims made for Wallets' extra assemblies: that they buy a substitutable read side. This part settles it, because the read path is the one place in the estate where the two architectures produce genuinely different code.

Two ways to answer “list my deposits”

Payments injects the DbContext into the query handler and projects in SQL:

return deposits.AsNoTracking()
    .Include(x => x.Account)
    .OrderByDescending(x => x.CreatedAt)
    .Select(x => new DepositDto
    {
        DepositId = x.Id,
        AccountId = x.AccountId,
        CustomerId = x.Account.CustomerId,
        Amount = x.Amount,
        Currency = x.Currency,
        Status = x.Status.ToString().ToLowerInvariant(),
        CreatedAt = x.CreatedAt,
        ProcessedAt = x.ProcessedAt
    })
    .PaginateAsync(query, cancellationToken);

(Payments.Core\Deposits\Queries\Handlers\BrowseDepositsHandler.cs, branch master.)

Wallets goes through an abstraction that returns the aggregate:

public Task<Paged<Wallet>> BrowseAsync(Expression<Func<Wallet, bool>> expression, IPagedQuery query)
    => _wallets
        .AsNoTracking()
        .AsQueryable()
        .Where(expression)
        .OrderBy(x => x.CreatedAt)
        .PaginateAsync(query);

(Wallets.Infrastructure\Storage\WalletStorage.cs.)

and the handler maps afterwards, in memory:

var result = await _storage.BrowseAsync(expression, query);
var wallets = result.Items.Select(x => x.AsDto()).ToList();

return Paged<WalletDto>.From(result, wallets);

Walk the difference. Payments emits one SELECT with exactly eight columns, shaped by the DTO, decided in one place, and the database never materialises a Deposit. Wallets emits a SELECT for the whole Wallets row, hydrates full Wallet aggregates — value objects reconstructed through their converters, one domain constructor call per column per row — and then projects to a DTO in application memory, discarding whatever it did not need.

The layered module cannot project, and the reason is its own abstraction. IWalletStorage returns Wallet. To project in SQL the storage would have to know about WalletDto, and WalletDto lives in the Application layer that Infrastructure is supposed to serve, not command. The interface that was introduced to keep persistence out of the query handler is exactly what stops the query from being efficient.

The over-fetch is worse where it matters

GetWallet is the single-item read, and it is the one customers hit:

public Task<Wallet> FindAsync(Expression<Func<Wallet, bool>> expression)
    => _wallets
        .AsNoTracking()
        .AsQueryable()
        .Where(expression)
        .Include(x => x.Transfers)
        .SingleOrDefaultAsync();

Every transfer the wallet has ever made, materialised, so that AsDetailsDto can call CurrentAmount() and build a List<TransferDto> of all of them. There is no paging on the transfer list inside a wallet — GET /wallets/{walletId} returns the entire ledger, always. For the demo data that is a handful of rows. For a wallet with five years of history it is a response nobody wanted and a fold nobody bounded.

And the browse path solves that by not answering the question at all. BrowseAsync does not Include transfers, and WalletDto — unlike WalletDetailsDto — has no Amount property:

internal class WalletDto
{
    public Guid WalletId { get; set; }
    public Guid OwnerId { get; set; }
    public string Currency { get; set; }
    public DateTime CreatedAt { get; set; }
}

So GET /wallets returns a list of wallets with no balances, and GET /wallets/{walletId} returns one wallet with a balance and its complete history. There is no middle setting. A client that wants “my three wallets and what is in each” makes four requests, three of which drag a full ledger across the wire to compute one number.

That is not a bug — it is a reasonable dodge of an expensive computation, and dropping Amount from the list DTO is how the module avoids folding N ledgers to render a page. But it is the derived-balance decision from part 2 presenting its invoice at the API surface, and no comment anywhere says so. A client author reads two DTOs, one of which inherits the other, and has to infer that the missing property is a performance boundary rather than an oversight.

What Payments pays instead

Being even-handed, the SQL-projection approach is not free.

The handler cannot be constructed without a database. BrowseDepositsHandler's only dependency is PaymentsDbContext, a concrete class. There is no seam to substitute. That is a real loss — and moot in practice, since Payments has no test project at all, as part 9 established.

The enum round trip is asymmetric. Status is stored as an integer, filtered with Enum.TryParse<DepositStatus>(query.Status, true, out var status), and rendered with x.Status.ToString().ToLowerInvariant(). Three representations of one field — integer in the column, enum in the filter, lower-cased string in the JSON — with the mapping between them written twice in every handler that touches it. The saving grace is that DepositStatus numbers its members explicitly (Started = 1, Completed = 2, Rejected = 3), so a reordering cannot silently reinterpret existing rows. That is a small, deliberate, correct decision, and it is the kind of thing that is easy to miss when tallying up a module's sins.

And the projection is duplicated per handler. Four browse handlers in Payments each write their own Select, and the shape of DepositDto is therefore defined in as many places as it is queried. Wallets' in-memory mapper is at least written once, in Queries\Handlers\Extensions.cs, with the transfer-type discrimination handled by a single switch expression.

Where the ownership rule ended up

One more thing the read side reveals, and it cuts across both modules: the rule “a customer may only see their own things” is expressed in three different ways across four Wallets read endpoints.

GET /wallets enforces it in the controller, by rewriting the query before dispatch:

if (query.OwnerId.HasValue || _context.Identity.IsUser())
{
    // Customer cannot access the other wallets
    query.OwnerId = _context.Identity.IsUser() ? _context.Identity.Id : query.OwnerId;
}

GET /wallets/{walletId} enforces it in the handler, after fetching, by returning null when the identity does not match — which the controller then turns into a 404. And GET /transfers and GET /transfers/{transferId} do not enforce it at all: both carry [Authorize("transfers")], so the endpoints are gated by permission instead, and GetTransferHandler returns any transfer by id — including its raw Metadata — to anyone holding that permission.

Each of those three is individually reasonable. Together they mean that “who can see this” is answered in a controller, a handler and an attribute, in one module, with a code comment in two of the three places. Neither architecture in this estate is responsible for that; it is what happens when authorisation is a habit rather than a layer. If your ownership rule appears in more than one kind of place, the interesting question is not which place is right — it is which endpoint you forgot.

The bug both modules share

Whatever they disagree about, both sides funnel into one shared helper — and it is wrong:

var totalResults = await data.CountAsync();
var totalPages = totalResults <= results ? 1 : (int) Math.Floor((double) totalResults / results);

(Shared.Infrastructure\Postgres\Extensions.cs.)

Math.Floor, where paging arithmetic wants Math.Ceiling. Twenty-five results at ten per page reports two pages, not three. The last partial page is unreachable to any client that trusts TotalPages and stops iterating when it gets there — and Paged<T> puts TotalPages and TotalResults in the response body, so the client can see the inconsistency without being able to do anything about it.

Eight query handlers across the estate reach this line, six of them in the two modules this series reads. The one piece of code both architectures share unchanged is the one with the arithmetic bug, which is a fair summary of the whole comparison. The same method also calls data.CountAsync() without the CancellationToken it was just handed, while passing the token faithfully to the ToListAsync two lines below.

The verdict

Claim B from the previous part — “layers give you a substitutable read side” — is technically true and practically inverted. The abstraction substitutes one ORM for another, which is a thing nobody does, and it costs the projection, which is a thing everybody needs. On the read path, the layered module is both the slower one and the more coupled one, coupled not to a database but to its own aggregate, which is a harder dependency to relax.

If I were keeping the four projects and fixing the read side, I would stop trying to route queries through the domain at all: define read models in the Application layer, let the Infrastructure layer implement IWalletQueries returning DTOs rather than IWalletStorage returning entities, and accept that the query side has no business loading an aggregate it will never mutate. That is the ordinary CQRS answer, and the module is two interfaces away from it.

The rule of thumb: an abstraction over a query pays on every request and protects an invariant that does not exist. Put the seam where the write side needs it, and let the read side see the database.

That closes the architecture arc. The last five parts follow the seams between modules, starting with the identity all of them silently share: one Guid, five customers.