Command/query separation
A function should either DO something (a command, with an effect) or ANSWER something (a query, with no observable side effect) — not both; separating them makes queries safe to call freely and your code predictable and testable.
A teammate adds one harmless line to a handler: logger.debug('current user: ' + currentUser()). Staging breaks. It turns out currentUser() doesn’t just return the user — the first time it’s called it also rotates the session token as a side effect. The log line called it a second time, rotated the token again, and logged the user out. The function read like a question; it was secretly also an action.
The bug isn’t the log line. The bug is a function that both answers and does. Once a single function carries both, you can never again call it casually — every read becomes a write you have to reason about, and “just add a log” becomes a production incident.
After this lesson you can classify any function as a command (causes an effect) or a query (returns a value with no observable effect); spot the smell of a query that secretly mutates and explain the double-call and log-statement bugs it produces; apply command/query separation to make queries free to call and code easier to test and reason about; and name the legitimate cases where an explicit combined operation is the honest design rather than dogmatically splitting it.
A function should either DO something or ANSWER something — never both. Bertrand Meyer’s command/query separation (CQS) draws one line through every method you write. A command changes observable state (mutates a field, writes a row, sends a request) and conventionally returns void or just the result of that effect. A query returns a value and leaves the world exactly as it found it — calling it zero times, once, or a hundred times makes no observable difference.
// query: answers, changes nothing observable
getBalance(): number;
// command: does, changes state
deposit(amount: number): void;The value of the rule is the guarantee it buys: when you see a query, you know it is safe to call. You can log it, assert on it in a test, call it twice, reorder it — and nothing downstream shifts. That single guarantee is what the rest of this lesson cashes in.
The classic violation is a getter that mutates — and it produces “spooky” double-call bugs. A function named like a question (currentUser, peek, nextId, a property getter) that also changes state is the trap, because every caller reasonably assumes it’s a query and calls it freely.
class IdGenerator {
private n = 0;
// looks like a query, is secretly a command
get current(): number {
return ++this.n; // mutates on every read
}
}
const gen = new IdGenerator();
console.log(gen.current); // 1
console.log(gen.current); // 2 — reading "current" twice gave different answersThe instant a “read” changes state, two truths a programmer relies on break: reading twice can return different answers, and adding a read (a log line, a debugger watch, an assertion) changes program behaviour. Now no one can inspect the value without risk. The fix isn’t subtle — make the read a real query and give the mutation its own command name.
Separated queries are referentially transparent enough to call freely — which is what makes code predictable and testable. Once a query has no side effect, you can substitute its result for the call without changing behaviour, hoist it out of a loop, cache it, or evaluate it in a test assertion with zero setup. Tests stop needing elaborate “and verify nothing else changed” scaffolding, because a query can’t change anything else.
// pure query: trivial to test, safe to call anywhere
function totalCents(items: LineItem[]): number {
return items.reduce((sum, i) => sum + i.cents, 0);
}
// command: test by checking the effect it caused, in isolation
function chargeCard(gateway: Gateway, cents: number): Promise<Receipt> {
return gateway.charge(cents);
}The asymmetry is deliberate: queries are tested for their return value, commands for their effect. Conflate them and every test has to assert both the answer and the state change — and you’ve lost the ability to use the function in a log line, a guard, or an assertion without consequences.
CQS underpins idempotency reasoning — and therefore safe retries. A pure query is automatically idempotent: calling it again costs nothing and changes nothing, so a retry, a refresh, or a duplicate request is free. Commands are where idempotency must be engineered (dedup keys, conditional writes), and CQS is what lets you see exactly where that engineering is needed. When reads and writes are tangled, you can’t reason about “is it safe to retry this?” at all, because every call might be doing both.
// safe to retry: a query does nothing the second time
const price = quotePrice(cart); // idempotent for free
// must be made idempotent on purpose: a command has an effect
await placeOrder(cart, idempotencyKey); // retry only safe with the keyThis is the senior payoff. In distributed systems, networks duplicate and retry requests for you whether you like it or not. CQS gives you a vocabulary — this is a query, retrying is free; this is a command, retrying needs a key — so duplicate-delivery reasoning becomes mechanical instead of a case-by-case panic.
A query with a hidden mutation, and the CQS fix. A cache exposes a lookup that looks like a pure read but also bumps an access counter for eviction:
class Cache<V> {
private store = new Map<string, V>();
private hits = new Map<string, number>();
// VIOLATION: named like a query, mutates on every call
lookup(key: string): V | undefined {
this.hits.set(key, (this.hits.get(key) ?? 0) + 1); // hidden write
return this.store.get(key);
}
}Now any if (cache.lookup(k)) guard, any console.log(cache.lookup(k)), any test assertion expect(cache.lookup(k)).toBe(v) silently changes eviction state. Run the test twice and the eviction order differs; add a debug log and a different key gets evicted in production. The smell is that a lookup has a write hiding inside it.
Split the command out from the query:
class Cache<V> {
private store = new Map<string, V>();
private hits = new Map<string, number>();
// QUERY: pure read, safe to call anywhere, any number of times
peek(key: string): V | undefined {
return this.store.get(key);
}
// COMMAND: the mutation, named honestly
recordHit(key: string): void {
this.hits.set(key, (this.hits.get(key) ?? 0) + 1);
}
}
// the caller now states intent explicitly
const value = cache.peek(key);
if (value !== undefined) cache.recordHit(key); // I am reading AND I want this countedpeek is now free to call in logs, guards, and tests; recordHit is the only thing that mutates, and it’s testable in isolation by checking the counter. Crucially, the caller decides when an access counts — which is the right place for that decision, not buried inside a read.
▸Why this works
Why does this matter more than it looks? Because CQS is what makes a function substitutable by its result. A query you can replace with the value it returns; you can move it, cache it, log it, retry it. The moment a read also writes, you lose every one of those moves — the function is welded to its single call site and its exact call count. CQS isn’t a style nicety; it’s the property that keeps reads cheap to think about, which is most of what makes a large codebase tractable. It’s also the spine of idempotency: “is retrying this safe?” has an instant answer for a pure query and a known engineering task for a command, instead of an unbounded investigation.
▸Common mistake
The dogmatic failure mode: splitting operations that are legitimately atomic. stack.pop() returning the popped value and removing it, queue.tryDequeue() returning a value plus a success flag while consuming the item, map.remove(key) returning the old value, an atomic compare-and-swap, an INSERT ... RETURNING id — these fuse a query and a command on purpose, because the read and the write must happen as one indivisible step. Forcing CQS here (a peek() then a separate remove()) reintroduces a race window between the two calls and is strictly worse under concurrency. The senior rule is not “never combine” — it’s combine only when atomicity demands it, and then name it honestly: pop, tryDequeue, getAndIncrement, fetchAndAdd all announce in the name that they both return and mutate. The crime in Step 2 wasn’t combining; it was combining while wearing a query’s name.
A method getNextToken(): string returns a token and also advances an internal counter so the next call returns a different token. A reviewer flags it. What is the correct CQS verdict?
Command/query separation says a function should either DO something (a command: it changes observable state, returns void or the effect’s result) or ANSWER something (a query: it returns a value and changes nothing observable) — but not both in disguise. The classic violation is a getter-shaped function that secretly mutates, which produces the spooky bugs where reading twice gives different answers and adding a log line changes behaviour. Keeping queries pure makes them free to call — safe in logs, guards, tests, and retries — which is what makes code predictable, testable, and what gives you a clean vocabulary for idempotency: a pure query is idempotent for free, while a command’s idempotency must be engineered. The failure mode is dogmatism: genuinely atomic operations like pop, tryDequeue, and getAndIncrement rightly fuse a read and a write, and forcing them apart reintroduces races. So combine only when atomicity demands it — and when you do, name it honestly so no caller mistakes a command for a query.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.