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?
answer
- decision test: invoke behavior vs. just read data
- cheap read model = query classes over same tables
- expensive read model = full CQRS w/ sync + eventual consistency
- governance = review checklist + periodic audits
- erosion happens one 'reasonable' finder at a time
basics
~20 sYou 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 sThe 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
Not generally expected to own this decision; should recognize that reporting and business-transaction data access can reasonably live in different places.
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.
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.
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