How would you enforce Command-Query Separation across a large codebase and an HTTP API, and where does the principle break down?
answer
- naming grammar + commands return void/outcome
- separate Reader/Writer ports; ArchUnit-style tests
- read-only transactions, replica routing fail loudly
- HTTP: safe methods = queries; idempotency keys for commands
- breaks on atomicity, generated ids, round trips, consuming reads
basics
~20 sEnforce it with conventions the tools can check: naming, return types (commands return nothing or just an outcome), separate command/query interfaces, and lint or architecture tests. On HTTP, map queries to safe methods like GET and commands to POST/PUT/DELETE.
solid answer
~50 sEnforcement works best when the rule is encoded in something mechanical: a naming grammar (`get/find/is` vs `create/apply/cancel`), commands returning nothing or only an outcome type, separate `*Reader`/`*Writer` or command-handler/query-handler interfaces, and an architecture test asserting that read-path types have no dependency on write-path repositories. Static analysis can flag mutations inside methods whose names or annotations claim purity. At the HTTP boundary CQS becomes protocol-level: queries are **safe** methods (`GET`, `HEAD`) that proxies, browsers and crawlers may repeat or prefetch; commands are unsafe and need idempotency keys to be retry-safe. It breaks down where read and write must be atomic (compare-and-swap, `pop`, `SELECT ... FOR UPDATE`), where a command must return server-generated data such as an identifier, where round trips are expensive, and where a "read" legitimately consumes a resource — a token, a queue message, a rate-limit budget. In those cases keep the mix, but make the name announce it and document the retry semantics.
go deeper
Say you'd keep the convention through clear naming — getters never change anything — and by having methods that change state return nothing.
Add commands returning void or a result type, queries returning copies/immutable values, and the HTTP mapping of queries to GET and commands to POST/PUT/DELETE.
Cover separate reader/writer ports, architecture tests and lint rules, read-only transactions that fail loudly, idempotency keys for retry-safe commands, and the atomicity cases where you deliberately keep a mix.
Present it as an enforceable policy with a hierarchy (correctness > clarity > brevity), a documented allow-list of sanctioned mixes, mechanical checks at several layers, and an explicit progression of the same principle from method naming to HTTP safe methods to read replicas and CQRS — while acknowledging that no compiler proves purity, so enforcement is always partial.
## Framing **Command-Query Separation (CQS)** — Meyer's rule that a method either mutates observable state (a **command**) or returns a value with no effects (a **query**) — is a *convention*. Conventions decay unless something mechanical holds them up. At principal level the interesting question is not "what is CQS" but "how do you make it survive a hundred engineers and a five-year codebase, and where do you consciously abandon it". --- ## Enforcement, from cheapest to strongest ### 1. A naming grammar The cheapest and most effective mechanism, because it makes violations visible in review and in call sites. - **Query prefixes:** `get`, `find`, `is`, `has`, `can`, `count`, `list`, `to…`, `as…`. - **Command verbs:** `create`, `apply`, `cancel`, `publish`, `close`, `assign` — domain verbs, imperative mood. - **Deliberate-mix names that announce the write:** `pop`, `poll`, `take`, `getAndIncrement`, `putIfAbsent`, `computeIfAbsent`, `tryLock`, `consumeToken`, `getOrCreate`. The rule is not "never mix" but **"never mix silently"**. Rule to write down: *a getter must never mutate; if a method both reads and writes, its name must say so.* ### 2. Types that encode the split - Commands return nothing, or a narrow **outcome** type (`Result`, `Either`, a status enum) rather than domain state. - Queries return **immutable** values or defensive copies, so the read guarantee holds transitively — a query handing back a live internal collection has effectively granted mutation rights. - Prefer **client-generated identifiers** so `create(id, payload)` returns nothing. Bonus: the write becomes naturally **idempotent**, which is what you want under at-least-once delivery. ### 3. Separate interfaces / ports Split a fat repository into `OrderReader` (queries) and `OrderWriter` (commands), or into explicit `CommandHandler<C>` / `QueryHandler<Q,R>` types. Now the split is visible in dependency graphs: a controller that only reads depends only on readers. This is the gateway drug to CQRS — same idea, one level up. ### 4. Automated checks - **Architecture tests** (ArchUnit-style, or a dependency-cruiser rule on the frontend): "types in the read package must not depend on write repositories", "classes named `*QueryService` must not call methods named `save*`/`delete*`", "methods annotated `@Query`/`@Pure` must not call mutating APIs". - **Static analysis / custom lint rules**: flag field assignment inside methods with query prefixes; flag `@Transactional(readOnly = false)` on read paths; flag mutation of parameters. - **Framework-level guards**: read-only transactions, read-replica routing for query paths — a read that tries to write then *fails loudly* instead of drifting. - **Code review checklist item** for the residue that tools can't see (network sends, metric writes, cache mutation). ### 5. Documented exception list Maintain a short, explicit allow-list of sanctioned mixes with their justification (atomicity / round trip / effect outcome). An unlisted mix is a review failure. This converts "we mostly follow CQS" into an auditable statement. --- ## CQS at the HTTP / service boundary The same rule appears in protocol form and is worth naming explicitly: - **Safe methods** (`GET`, `HEAD`, `OPTIONS`) are the protocol's word for "query": intermediaries, browsers, crawlers and prefetchers may repeat them freely. Putting a mutation behind `GET` is not just untidy — a link prefetcher or a crawler will execute it. (The historically famous failures were admin UIs exposing delete-as-link.) - **Idempotent methods** (`PUT`, `DELETE`) may be retried safely even though they mutate; `POST` may not. - **Idempotency keys** are how you make a non-idempotent command retry-safe: the client sends a unique key, the server records it and returns the original result on replay. This is CQS-adjacent reasoning applied to networks, where at-least-once delivery is the norm. - **Caching** follows the same line: only safe methods are cacheable, which is exactly "only queries can be memoized" at internet scale. - **Read replicas / CQRS** extend it further: if query paths are provably write-free, they can be routed to replicas or dedicated read models. The enforcement work in section 3 is what makes that routing safe to attempt. --- ## Where CQS breaks down 1. **Atomicity.** Splitting a read+write into two calls creates a check-then-act race. Compare-and-swap, `Queue.poll()`, `Iterator.next()`, `AtomicInteger.getAndIncrement()`, `Map.putIfAbsent()`, `SELECT ... FOR UPDATE`, `UPDATE ... RETURNING` all exist because correctness beats tidiness. This is the exception with the strongest claim. 2. **Server-generated data.** Identifiers, timestamps, version numbers, computed totals — a strict command returns nothing, so the caller must issue a follow-up query, which costs a round trip and may race. Mitigate with client-generated ids where you can; accept the return where you can't. 3. **Round-trip cost.** In distributed or database contexts, one fused statement replaces two; `getOrCreate` and read-through caching exist for this reason. 4. **Consuming reads.** Some domains have reads that legitimately consume: dequeue a message, burn a one-time token, draw from a rate-limit budget, hand out a sequence number. These are commands that happen to look like reads; the API must say so, and retry semantics must be documented. 5. **Ergonomics and fluency.** Builder chains, fluent APIs, and functional-collection pipelines return values while producing new state; enforced dogmatically, CQS produces verbose APIs that engineers route around. 6. **The rule is unverifiable in general.** No mainstream language proves purity for you (effect systems and `const`-correctness are partial and easily subverted). Enforcement is therefore always partial — naming plus tests plus review, never a compiler guarantee. --- ## The judgment call State the policy as a hierarchy: **correctness (atomicity, races) > clarity (CQS) > brevity**. Default to CQS everywhere; break it only for a reason on the allow-list; when broken, the name must announce the write and the docs must state the retry semantics. Then push the same reasoning up the stack — safe HTTP methods, idempotency keys, read replicas, and eventually CQRS where read and write models genuinely diverge. That progression, from method naming to protocol semantics to architecture, is the same principle at three scales.
- How do you keep a non-idempotent command retry-safe when the network can lose responses?Idempotency keys: the client generates a unique key per logical operation and sends it with the request; the server stores key → outcome and, on replay, returns the recorded result instead of re-executing. Equivalent tactics are client-generated entity ids (a repeated create collides on the primary key) and conditional writes using an expected version (optimistic concurrency).
- What is the risk of exposing a state-changing operation behind an HTTP GET?Safe methods are assumed repeatable by everything in the path — browsers, proxies, caches, link prefetchers, crawlers, and retrying clients. Any of them can execute your mutation without a user acting, potentially many times. It also makes the response cacheable, so subsequent 'writes' may silently not reach the server at all.
- Can you actually verify CQS automatically?Only partially. Mainstream languages have no purity proof; effect systems, immutability by construction, and read-only transactions cover part of it, and architecture tests plus lint rules on naming and dependencies cover more. The residue — network sends, metrics, cache writes, mutation through returned references — needs review discipline and a documented exception list.