skip to content

In a CQRS architecture, why must the query side never mutate state, even indirectly (e.g. lazy-computing and caching a value inside a query handler)?

level: middleimportance: must knowfreq 65%

answer

  1. CQS: command changes state, query returns data, never both
  2. retries make mutating queries dangerous
  3. read replicas assume no writes
  4. safe exception = idempotent, correctness-invisible side effects (cache-aside)
  5. get-or-create hides a command inside a query

basics

~20 s

A query is only supposed to look at data and return an answer — it should never change anything while doing so. If reading data quietly changes it, you lose the ability to trust that 'just reading' is safe to repeat, retry, or run in parallel.

solid answer

~40 s

This is Command-Query Separation applied to CQRS's read side: a query handler's job is to answer a question, not to change system state, even as a side effect like lazily computing and persisting a cached value, incrementing a 'views' counter in the same path as the domain data, or triggering a workflow. If queries can mutate state, the query side is no longer safely cacheable, replayable, retryable, or parallelizable — a retried GET could double-apply a side effect, and a read replica or read-model rebuild could suddenly produce different data depending on which queries happened to run against it. Any state change the system needs (denormalizing on read, updating a cache, incrementing a counter) belongs on the write/command side, published as an explicit event or command, even if it's triggered by a read.

go deeper

for a junior

Should be able to say a query just reads and answers, it shouldn't change data, in plain terms — 'looking shouldn't change what's there.'

for a middle

Should recognize common violations (view counters, get-or-create, inline caching) and explain in general terms why they're risky (retries, caching, replicas).

for a senior

Should explain concretely how to make a legitimate side effect (like cache population) safe — idempotency, invisibility to correctness — versus when it should instead be a named command.

for a principal

Should connect this to system-wide infrastructure assumptions (load balancer routing, read-replica architecture, retry semantics across the whole platform) and describe how a violation degrades those guarantees at scale, not just in one handler.

## The rule and where it comes from **Command-Query Separation (CQS)**, a principle articulated by Bertrand Meyer and adopted as one of the founding ideas behind CQRS, says that every method should either be a command that changes state and returns nothing meaningful, or a query that returns data and changes nothing — never both. On the read/query side of a CQRS system, this means a query handler is allowed to read from a read model (or any store) and shape a response, but it must not, as part of executing that read, write to the write model, mutate the read model outside of the normal event-driven projection path, increment counters, or trigger side effects like sending emails or kicking off workflows. Common violations look innocuous in isolation: - a query handler that notices a cached value is missing and computes-and-stores it right there in the request path - an endpoint that increments a 'view count' column inline while fetching an article - a 'get or create' query that silently inserts a default record the first time it's called for a new user ## What the word 'query' promises The reason this matters is that 'query' carries an implicit contract with everyone downstream of it: - it's safe to call repeatedly - safe to call in parallel - safe to retry after a timeout - safe to route to any read replica - safe to run against a point-in-time snapshot without changing what that snapshot means Load balancers and API gateways route 'read' traffic (`GET` requests, query buses) differently from 'write' traffic — often to read-only replicas, edge caches, or CDNs — precisely because they assume queries don't need write access and can be freely duplicated, retried, or served from a stale copy. The moment a query mutates state, all of those assumptions break: a request that times out and gets retried by a client library can double-apply the side effect; a query fired twice by an over-eager frontend (double-click, race condition) does the mutation twice; a load test or crawler that hammers a 'read' endpoint accidentally generates real writes; and a read replica that isn't supposed to accept writes either fails outright or silently diverges from the primary. ## The legitimate exception There are legitimate cases where reading triggers computing something new — **lazy cache population** and **read-time materialization** are real, useful patterns — but the correct implementation keeps the mutation out of the query's contract. A cache-aside read can compute a value and write it into a cache, as long as that write is: - provably **idempotent** (writing the same value twice is harmless) - invisible to the caller's correctness (the response is correct whether or not the cache write succeeds) - ideally routed through the same event-driven or asynchronous path the rest of the read model uses, rather than a bespoke write buried inside the query handler The anti-pattern isn't 'a read ever causes a write anywhere in the system' — it's a query whose correctness or observable behavior depends on that write succeeding, or that treats the mutation as part of what 'querying' means. ## Failure modes Failure modes in production tend to show up as concurrency bugs and infrastructure mismatches rather than obvious crashes. 1. **Contention.** Under load, many concurrent requests hitting a 'helpful' mutating query can race to write the same computed value, causing lock contention, duplicate rows, or lost-update bugs if the write isn't upsert-safe. 2. **Replicas.** Systems that scale reads horizontally by adding read replicas discover that mutating queries either can't run there at all (replicas are often read-only at the database level) or, if they can, cause replication conflicts. 3. **Retry.** Retried requests — extremely common in distributed systems, where a client can't tell a timeout apart from a lost response — silently double-apply whatever the query mutated, and because it's 'just a GET,' nobody built idempotency keys for it the way they would for a payment command. 4. **Confusing dashboards.** Observability gets confusing too: write-path dashboards (write latency, lock waits, replication lag) start showing traffic correlated with GET request volume, and nobody can explain why until someone finds the inline write in the query handler. ## Seeing it break, and the fix A concrete example: a reporting dashboard's `GET /reports/monthly-summary` endpoint recomputes an aggregate over recent orders and writes the result into a summary table directly in the request handler, so subsequent requests are fast. Under normal traffic this works fine. During a traffic spike or a bot crawling the API, dozens of concurrent requests for the same report all miss the 'cache' simultaneously, all recompute the aggregate, and all try to write it back at once — the summary table sees lock contention, some requests time out, and the endpoint that was supposed to be a cheap read becomes the slowest, most write-contended path in the system. The fix is to move the aggregation into the write side: a background job or event-driven projector recomputes and stores the summary whenever the underlying orders change, and the GET handler only ever reads whatever is currently there, even if that means occasionally serving a summary that's a few seconds stale.

  • If lazy cache population inside a query handler isn't a CQS violation, what makes it different from an obviously bad example like incrementing a view counter inline?
    The cache write is idempotent and invisible to correctness — writing the same computed value twice is harmless, and the response would be correct whether or not the write succeeds or is retried. A view-counter increment is neither: retrying it changes the result, and the correctness of 'the count' depends on exactly how many times the increment ran.
  • How would you design a 'get or create' operation — where fetching a resource that doesn't exist yet should create a default one — without violating query/mutation separation?
    Split it into two explicit steps at the API or bus level: a query that returns 'not found' or a default value without persisting anything, and a separate, explicitly-named command (e.g., InitializeDefaultProfile) that the caller — or an idempotent background process — invokes to actually create the record. The mutation stays visible and named, instead of being hidden inside what looks like a read.
  • Why do read replicas typically enforce that they can't accept writes at the database engine level, and how does that relate to keeping queries mutation-free?
    Replicas apply a stream of changes from the primary in a fixed order to stay consistent with it; allowing local writes on a replica would create data the primary doesn't know about and that can conflict with incoming replication, breaking that consistency guarantee. Enforcing read-only at the engine level backstops the same discipline application code is supposed to follow — that queries, wherever they run, don't mutate state.

A query should behave like checking a library catalog — looking up whether a book is available shouldn't reshelve, damage, or check out the book. If glancing at the catalog quietly changed the book's status, two people checking at once could get contradictory answers, and a librarian scanning the shelf twice by mistake could double-book it.

saying these in an interview costs you the question

  • Thinks it's fine for a query to update a cache or counter as long as it 'still returns the right data'
  • Can't explain why retries are dangerous for a mutating query
  • No mention of idempotency when discussing legitimate lazy-computation patterns
  • Assumes GET/query endpoints are automatically safe to route anywhere without checking whether they mutate
  • Confuses 'query returns computed data' with 'query is allowed to persist that computed data as a side effect'

context