skip to content

Name several widely accepted violations of Command-Query Separation in real APIs and explain why each exception is justified.

level: middleimportance: must knowfreq 46%

answer

  1. atomicity beats clarity: pop / poll / next / CAS
  2. getAndIncrement, putIfAbsent, tryLock, computeIfAbsent
  3. split read+write = check-then-act race window
  4. cost bucket: getOrCreate, read-through cache, UPDATE…RETURNING
  5. command may report its own outcome: id, rows affected, success

basics

~10 s

Common accepted mixes: stack pop(), queue poll(), iterator next(), compare-and-swap, atomic getAndIncrement, putIfAbsent, and getOrCreate. Each needs the read and write to happen as one atomic step, which two separate calls cannot guarantee.

solid answer

~50 s

The recurring justification is **atomicity**. Splitting a mixed operation into `peek()` then `remove()` creates a window where another thread — or another request — can interleave, so the value you read is not the value you removed. `pop()`, `Queue.poll()`, `Iterator.next()`, `AtomicInteger.getAndIncrement()`, `Map.putIfAbsent()`, and compare-and-swap all exist precisely to close that window; CAS is the extreme case, since "write if unchanged, and tell me whether you won" is meaningless if separated. A second family is **cost**: `getOrCreate`/`computeIfAbsent` and cache lookups avoid a double traversal or a double round trip, and `deleteAndReturn`/`UPDATE ... RETURNING` saves a network hop in a database. A third is **information the effect itself produced**: a command returning the generated identifier, the number of rows affected, or a success/failure result is generally considered compatible with CQS — it reports the outcome rather than answering a separate question. Meyer's rule stays the default; exceptions should be few, named, and obvious.

code

pseudocode · 11 lines
pseudocode
// CQS-pure, but racy: another thread can act between the calls
if (!queue.isEmpty()) {
    item = queue.peek();
    queue.removeFirst();      // may remove a DIFFERENT item
}

// Accepted CQS violation: one indivisible read+write
item = queue.poll();          // null/empty-signal if nothing there

// The archetype: compare-and-swap is meaningless if split
succeeded = cell.compareAndSet(expected: 5, newValue: 6);

go deeper

for a junior

Name two or three familiar exceptions — pop(), Iterator.next(), a counter's increment-and-return — and say they mix read and write on purpose so nothing can slip in between.

for a middle

Organise the exceptions into atomicity, cost, and outcome-reporting, and explain the check-then-act race that splitting creates.

for a senior

Lead with compare-and-swap as the archetype, discuss lock-free algorithms and lost updates, cover the contested cases (returning generated ids, returning updated entities) and the naming rule that makes a mix acceptable.

for a principal

Frame it as a policy: CQS as the default, a short explicit allow-list of fused operations justified by atomicity or a round trip, mandatory self-announcing names, and a preference for client-generated identifiers so writes stay idempotent and retry-safe in distributed systems.

## Why exceptions exist at all **Command-Query Separation (CQS)**, Bertrand Meyer's rule, says a method should either mutate state (a **command**) or return a value (a **query**), never both. It is a superb default. But it is a *style* rule with no formal backing, and three forces push back on it: **atomicity**, **cost**, and **the outcome of the effect itself**. Every well-known exception falls into one of those three buckets. --- ### Bucket 1 — Atomicity (the strongest justification) Splitting one mixed operation into a query plus a command creates a **race window**: an interval between the two calls in which some other thread, process, or request can change the thing you just read. This is the classic **check-then-act** / **read-modify-write** bug. ``` // CQS-pure but broken under concurrency if (!queue.isEmpty()) { // query item = queue.peek(); // query queue.removeFirst(); // command } // another thread can empty the queue between any two lines ``` Operations that deliberately fuse read and write to close that window: | Operation | What it does | Why it must be fused | |---|---|---| | `Stack.pop()` | returns the top element **and** removes it | two threads calling `peek()` then `remove()` can both get the same element, or one can remove the other's element | | `Queue.poll()` / `take()` | removes and returns the head, possibly blocking | a work queue must hand each item to exactly one consumer | | `Iterator.next()` | returns the current element **and** advances the cursor | the cursor position *is* the state; separating them invites double-advance or missed elements | | **Compare-and-swap (CAS)** | "write `new` only if the current value is still `old`; return whether it succeeded" | this is the canonical exception — the whole point is that the comparison and the write are indivisible. Split it and the primitive ceases to exist. CAS underlies nearly all lock-free algorithms | | `AtomicInteger.getAndIncrement()` / `incrementAndGet()` | bumps a counter and returns a value | `read; add 1; write` loses updates under contention | | `Map.putIfAbsent()` / `computeIfAbsent()` | inserts only if missing, returns existing-or-new | prevents two threads both concluding "absent" and both inserting | | `Lock.tryLock()` | acquires the lock and reports success | acquiring is the effect, the boolean is the only way to know if you got it | | `Semaphore.tryAcquire()` | same shape | idem | | Database `SELECT ... FOR UPDATE`, `UPDATE ... RETURNING`, `INSERT ... ON CONFLICT ... RETURNING` | read and write in one statement | avoids a lost update or a phantom between two statements | The general principle: **CQS is about clarity; atomicity is about correctness. When they conflict, correctness wins.** --- ### Bucket 2 — Cost / round trips - `getOrCreate(key)`, `computeIfAbsent(key, factory)` — a CQS-pure version does the lookup twice (once to test, once to fetch), which for a hash map is cheap but for a remote cache or a database is a doubled round trip. - Cache `get()` that populates on miss (read-through cache) — returns a value *and* writes to the cache. Usually excused as a **benign side effect** because the cache is not observable domain state, but it stops being benign under concurrency (stampede) and when the cache is shared and metered. - Bulk APIs that return what they changed (`deleteExpired()` returning the count) — avoids a second scan. --- ### Bucket 3 — Reporting the outcome of the effect This is where teams disagree, and it is worth being explicit in an interview: - A command returning **success/failure** or throwing on failure — essentially universally accepted; Meyer's own approach used exceptions and preconditions. - A command returning a **server-generated identifier** (`createOrder(...) -> orderId`) — widely accepted, since the caller has no other way to learn the id and the id is a product of the effect, not an independent question about state. Purists prefer client-generated ids (a UUID chosen by the caller) so that `create` can return nothing — which also makes the operation **idempotent**, a big win for retry-safe distributed APIs. - A command returning the **full updated entity** (`update(...) -> User`) — the most contested. It saves a round trip in HTTP APIs, and REST convention endorses returning the representation, but it does blur the split and encourages callers to depend on write responses for reads. - `remove(key)` returning the removed value, `list.add()` returning a boolean — small conveniences that are effectively unavoidable in mature standard libraries. --- ### Non-exceptions: mixes you should still refuse - A getter that lazily *and observably* mutates domain state — e.g. `getBalance()` that also applies pending interest. Now logging the balance changes the balance. - A validation method that also persists (`validateAndSave()`), unless that atomicity is genuinely required and the name says so. - A "read" endpoint that writes audit rows or increments quota counters without saying so, breaking retry safety and prefetching. - Anything where the mutation is a **surprise** given the name. The real, enforceable version of CQS is: *if a method both reads and writes, its name must make the write obvious* (`pop`, `getAndIncrement`, `tryLock`, `putIfAbsent` all do exactly this). --- ### How to talk about this A strong answer sounds like: "CQS is my default because it makes reads free to repeat, cache, log, and parallelize. I break it when the read and the write must be one atomic step — pop, poll, CAS, getAndIncrement, putIfAbsent — because splitting them introduces a race, and races are correctness bugs while CQS is a clarity heuristic. When I break it, the method name announces the mutation."

  • How would you make a create-command CQS-compliant when the caller needs the new record's ID?
    Let the caller supply the identifier — generate a UUID (or another client-side key) before calling — so `create(id, data)` returns nothing. This restores the pure command, and as a bonus makes the operation idempotent: a retried create with the same id is safely a no-op, which matters under at-least-once delivery.
  • Is a read-through cache that populates on a miss a CQS violation?
    Technically yes — the read mutates the cache. It is normally excused as a benign, unobservable side effect since no domain state changes and the answer is identical either way. It stops being benign when the cache is shared and contended (cache stampede), when eviction makes results non-deterministic, or when cache writes are metered/billed — at that point document it as a command-ish read.

A vending machine could let you look through the glass (query) and then press a button to drop the snack (command) — but between the two, someone else buys the last one. pop() is the machine handing you the item and decrementing stock in a single motion; you trade a little conceptual tidiness for the guarantee that what you saw is what you got.

context