skip to content

In a system with both a rich write-side aggregate repository and heavy reporting/read needs, how do you decide the boundary between what stays on the aggregate repository versus what moves to a separate read model, and what keeps that boundary from eroding as the system grows?

level: principalimportance: nice to knowfreq 40%

answer

  1. decision test: invoke behavior vs. just read data
  2. cheap read model = query classes over same tables
  3. expensive read model = full CQRS w/ sync + eventual consistency
  4. governance = review checklist + periodic audits
  5. erosion happens one 'reasonable' finder at a time

basics

~20 s

You draw a line based on whether something needs a full business object to make decisions with (that stays with the repository) or just needs to show/report data (that moves to a simpler, faster read-only path) - and you keep re-checking that line as the system grows, because there's constant pressure to blur it for convenience.

solid answer

~50 s

The boundary test is behavioral: does this operation need to load a real aggregate and invoke its domain methods (so invariants get enforced), or does it only need data to display, filter, sort, or aggregate? The former stays on the aggregate repository; the latter belongs on a separate read model - denormalized tables, materialized views, or a full CQRS read side, depending on how much divergence from the write schema is worth the operational cost of maintaining a second representation. What keeps the boundary from eroding is treating any read-model bypass (adding a query method to the aggregate repository, or letting application code mutate through a 'read' path) as an explicit, reviewed exception rather than an ad hoc convenience, and periodically auditing repository interfaces for accumulated finder methods that no longer correspond to genuine domain questions.

go deeper

for a junior

Not generally expected to own this decision; should recognize that reporting and business-transaction data access can reasonably live in different places.

for a middle

Should be able to apply the behavioral test (does this need a real aggregate?) when told the two options, even if unfamiliar with CQRS terminology.

for a senior

Should be able to choose between simple read-only query classes and a fuller CQRS read side for a given reporting need, and justify the choice by the cost/benefit trade-off.

for a principal

Should be able to set and enforce org-level review practices that prevent repository-interface erosion over years, and make the build-vs-avoid-CQRS call with awareness of the operational cost (sync pipelines, eventual consistency, extra stores) it introduces.

## Two different jobs The boundary between an aggregate's repository and a separate read model is fundamentally a boundary between two different jobs: **protecting invariants during state changes**, versus **answering questions about state**. - An **aggregate repository** exists to load a whole, invariant-consistent aggregate so that domain logic can be invoked on it and the result saved atomically — that's expensive by design, because consistency is expensive by design. - A **read model** exists purely to answer questions — filter this, sort that, sum this — and has no obligation to load anything resembling a full aggregate, enforce any invariant, or even represent data in the same shape the write side uses internally. ## The decision rule is behavioral, not structural The decision rule that keeps this boundary sharp is behavioral, not structural: ask whether the calling code, after getting data back, - needs to invoke a domain method that could fail an invariant check (in which case it needs a real aggregate from the repository), - or whether it only ever reads the data to display, export, filter, or aggregate it (in which case a read model, decoupled from the aggregate's shape and cost, is the right tool). This test generalizes well because it doesn't depend on how 'complex' the query looks — a single-field lookup used to drive a domain decision belongs on the repository; a ten-column joined report used only for display belongs on the read side. ## How much divergence is worth building Once the boundary is drawn, the practical question is how much divergence between write and read representations is worth building. | Read side | What it is | What comes with it | |---|---|---| | At the cheap end | a read model can be nothing more than a set of hand-written SQL query classes or views reading the same tables the write-side repository uses, returning DTOs instead of aggregates | no additional infrastructure, no synchronization lag, just a second, honestly-named access path that isn't pretending to respect the aggregate abstraction | | At the expensive end | a full CQRS read side maintains a separately-optimized, potentially separately-stored representation (denormalized tables, a search index, a different database engine entirely) kept in sync via domain events | accepting eventual consistency between write and read in exchange for read performance and shape that can diverge arbitrarily from the aggregate's internal structure | The trade-off scales with the gap: the further reporting needs get from 'the aggregate's own shape, just read-only', the more a separate materialized representation earns its keep, but every step toward full CQRS adds - synchronization machinery, - a new class of bugs (a read model out of sync with the write model, for a window of time or, if the sync pipeline breaks, indefinitely), - and operational surface (another store, another migration path, another thing that pages someone at 3am). ## What keeps the boundary from eroding What actually keeps the boundary from eroding, in practice, has less to do with the initial design decision and everything to do with **team governance over time**, because the pressure to blur the line is constant and individually always looks reasonable: a query is urgently needed, adding one more finder method to the existing, familiar aggregate repository interface feels faster than standing up a new read-model class, and it usually is faster in the moment. Left unmanaged, this produces exactly the accumulation described as a failure mode elsewhere in this topic — a repository interface that's technically still 'per aggregate' but has quietly become a general-purpose query surface with dozens of narrowly-specific methods that map to UI filters rather than domain concepts. The countermeasure that works is: 1. treating any addition to the aggregate repository's interface as something a code review should explicitly ask 'does this need a real aggregate to invoke behavior on, or does it just need data' about, rather than nodding it through because it superficially matches the existing method style; 2. and periodically (not just reactively) auditing repository interfaces for methods that no longer serve a domain-behavior caller, migrating those to the read side as a scheduled cleanup rather than leaving the interface to keep growing indefinitely. ## A governance example A concrete governance example: a logistics platform's `ShipmentRepository` originally had a clean four-method interface (`findById`, `add`, `findPendingPickups`, `findByTrackingNumber`) that mapped directly to real domain workflows. Over eighteen months, seven more finder methods were added, each individually justified by a real feature request, until an architecture review discovered that five of the new seven were only ever called by controller code building JSON responses for a customer-facing tracking page — none of them ever invoked a domain method on the returned `Shipment`. The team's fix wasn't a rewrite; it was extracting those five into a `ShipmentTrackingQueries` read-model class backed by the same tables (no new infrastructure needed, since the divergence from the write shape was small), leaving `ShipmentRepository` back down to methods that genuine domain workflows use, and adding a lightweight review checklist item — 'does this repository method's caller invoke domain behavior on the result?' — specifically to catch the next drift before it accumulated to the same degree again.

  • How would you decide whether a new reporting need justifies a full CQRS read side versus a simpler set of read-only query classes over the existing schema?
    Look at how far the needed representation diverges from the write schema and how much read latency/throughput the feature demands - if a few joins over existing tables answer it fast enough, plain query classes are far cheaper to build and operate than a synchronized read store. Full CQRS earns its cost when the read shape needs to diverge substantially (denormalized, search-indexed, or aggregated across many aggregates) or read load is high enough that querying the write-side schema directly would compete with write-side transactions for resources.
  • What's a concrete review checklist question that helps prevent aggregate-repository interface erosion?
    For any new repository method, ask whether its caller invokes a domain method on the returned aggregate that could fail an invariant, or only reads/displays/exports the data - if it's purely the latter, the method belongs on a read model, not the aggregate repository, regardless of how convenient adding it to the existing interface feels in the moment.
  • Does moving reporting needs to a separate read model mean the aggregate repository becomes irrelevant for anything read-heavy?
    No - the aggregate repository still needs to efficiently serve the read-then-mutate-then-save cycle that domain workflows actually use, which is a different performance profile than reporting (single-aggregate loads driven by a known ID, not broad scans/joins). Optimizing that path (indexes on lookup keys, sensible loading strategy) remains part of the repository's job even after reporting is offloaded.

It's like a company keeping one authorized signatory who alone can approve contracts (protecting real commitments) while giving everyone else read access to a public dashboard summarizing deal status - convenient shortcuts to let dashboard viewers also 'just quickly' approve one small thing are exactly what eventually turns the dashboard into a second, ungoverned approval channel.

saying these in an interview costs you the question

  • Adds every new query need to the aggregate repository interface because it's the familiar/existing class
  • Builds a full CQRS read side for a reporting need that a simple read-only query class over existing tables would have served
  • Has no review practice catching repository-interface growth, relying only on individual developer judgement each time
  • Treats eventual consistency in a CQRS read model as a bug to be eliminated rather than an accepted, bounded trade-off
  • Can't articulate the specific behavioral test (invoke domain behavior vs. just read data) used to decide where a query belongs

context