---
title: 'Command-Query Separation'
source: 'https://academia.sh/en/courses/design-principles/command-query-separation'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:16+00:00'
license: 'CC BY-SA 4.0'
---

# Command-Query Separation

Separating operations that change state from ones that only read it: writing a scan that finds methods which both write and return, counting how many records a report-only path changes, and showing with a run that a log line writes the opposite of what actually happened.

The previous lesson's `depart` method was named like a command, but it only returned a
result — the shipment's state did not change. A real dispatch operation must change state
too. When a method takes on both jobs at once, a new question follows: what does calling the
same method a second time change?

**Command–query separation** was introduced in the Programming Fundamentals course: an
operation either changes state and returns no value (a command), or returns a value and
changes no state (a query). This lesson carries the principle into object design and measures
the cost of violating it with two numbers: the count of methods that both write and return,
and the number of records a read-only path changes.

## Mixed Method

In the first design, `depart` both writes the `state` field and returns a result.

```sh
mkdir -p mixed separated
```

```js
// mixed/records.mjs — a generator so every trial starts with fresh records
export const newRecords = () => [
  { code: "GN-1", state: "warehouse", paid: true, weight: 12 },
  { code: "GN-2", state: "warehouse", paid: true, weight: 45 },
  { code: "GN-3", state: "in_transit", paid: true, weight: 8 },
  { code: "GN-4", state: "warehouse", paid: false, weight: 5 },
  { code: "GN-5", state: "warehouse", paid: true, weight: 25 },
];
```

```js
// mixed/shipment.mjs — a single method both changes state and returns a value
const WEIGHT_LIMIT = 30;

export const shipment = (record) => ({
  code: record.code,
  depart() {
    if (record.state !== "warehouse") return { ok: false, reason: "not_in_warehouse" };
    if (record.paid === false) return { ok: false, reason: "unpaid" };
    if (record.weight > WEIGHT_LIMIT) return { ok: false, reason: "weight_limit" };
    record.state = "in_transit";
    return { ok: true, reason: "departed" };
  },
});
```

```js
// mixed/dispatch.mjs — dispatches first, then re-queries the reason to write to the log
export function dispatch(shipments) {
  const departed = shipments.filter((s) => s.depart().ok).map((s) => s.code);
  const log = shipments.map((s) => `${s.code}=${s.depart().reason}`);
  return { departed, log };
}
```

```js
// mixed/report.mjs — a report that only wants to write the reasons
export const report = (shipments) => shipments.map((s) => `${s.code}=${s.depart().reason}`);
```

The report module does not want to dispatch anything; it only wants to write down each
shipment's state. With only one method available to ask, it calls that one.

## Separated Methods

In the second design, the same information is split into two methods. `departureBlocker` is
a query: it reads state, it does not change it. `depart` is a command: it changes state and
returns no value; it throws for a shipment that cannot depart.

```js
// separated/records.mjs — a generator so every trial starts with fresh records
export const newRecords = () => [
  { code: "GN-1", state: "warehouse", paid: true, weight: 12 },
  { code: "GN-2", state: "warehouse", paid: true, weight: 45 },
  { code: "GN-3", state: "in_transit", paid: true, weight: 8 },
  { code: "GN-4", state: "warehouse", paid: false, weight: 5 },
  { code: "GN-5", state: "warehouse", paid: true, weight: 25 },
];
```

```js
// separated/shipment.mjs — the query does not change state, the command returns no value
const WEIGHT_LIMIT = 30;

export const shipment = (record) => ({
  code: record.code,
  departureBlocker() {
    if (record.state !== "warehouse") return "not_in_warehouse";
    if (record.paid === false) return "unpaid";
    if (record.weight > WEIGHT_LIMIT) return "weight_limit";
    return null;
  },
  depart() {
    const blocker = this.departureBlocker();
    if (blocker !== null) throw new RangeError(`${record.code} cannot depart: ${blocker}`);
    record.state = "in_transit";
  },
});
```

```js
// separated/dispatch.mjs — first asks, writes to the log, then issues the command
export function dispatch(shipments) {
  const departed = [];
  const log = [];
  for (const s of shipments) {
    const blocker = s.departureBlocker();
    log.push(`${s.code}=${blocker ?? "departed"}`);
    if (blocker === null) {
      s.depart();
      departed.push(s.code);
    }
  }
  return { departed, log };
}
```

```js
// separated/report.mjs — a report that only wants to write the reasons
export const report = (shipments) =>
  shipments.map((s) => `${s.code}=${s.departureBlocker() ?? "departed"}`);
```

## The Surprise Shown by the Run

The driver script runs two trials. In the first, only the report is called, and the number of
records whose state the report changes is counted. In the second, the dispatch operation
runs, the log lines are written, and the same operation is called a second time.

```js
// setup.mjs — three trials: report, dispatch log, and calling the same operation a second time
const root = process.argv[2];
const { newRecords } = await import(`./${root}/records.mjs`);
const { shipment } = await import(`./${root}/shipment.mjs`);
const { report } = await import(`./${root}/report.mjs`);
const { dispatch } = await import(`./${root}/dispatch.mjs`);

const records = newRecords();
const before = records.map((r) => r.state);
console.log("report      ", report(records.map(shipment)).join(" "));
const after = records.map((r) => r.state);
console.log(`records changed by report = ${before.filter((d, i) => d !== after[i]).length}`);

const secondRecords = newRecords();
const shipments = secondRecords.map(shipment);
const first = dispatch(shipments);
console.log("dispatch 1 departed", first.departed.join(" ") || "(empty)");
console.log("dispatch 1 log", first.log.join(" "));
const second = dispatch(shipments);
console.log("dispatch 2 departed", second.departed.join(" ") || "(empty)");
```

```sh
node setup.mjs mixed
```

```
report       GN-1=departed GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=departed
records changed by report = 2
dispatch 1 departed GN-1 GN-5
dispatch 1 log GN-1=not_in_warehouse GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=not_in_warehouse
dispatch 2 departed (empty)
```

Two surprises at once. The report set two records' state to `in_transit`: a code path that
never meant to dispatch anything sent two shipments off. The log line, in turn, wrote the
opposite of what actually happened — `not_in_warehouse` shows up for the dispatched GN-1 and
GN-5, because by the time the reason was asked, the state had already changed.

```sh
node setup.mjs separated
```

```
report       GN-1=departed GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=departed
records changed by report = 0
dispatch 1 departed GN-1 GN-5
dispatch 1 log GN-1=departed GN-2=weight_limit GN-3=not_in_warehouse GN-4=unpaid GN-5=departed
dispatch 2 departed (empty)
```

The report line is the same, but the number of records it changes is zero. The log line now
writes the truth too. The dispatch result is identical in both versions; the separation did
not change behavior, it only made observation safe.

## Found by Scanning

A violation can also be found without waiting for a run. The script below looks at the body
of object methods: a method that writes to the record is a command, one with a return value
is a query, and one with both is a finding.

```js
// command-query-scan.mjs — finds methods that both change and return state
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const METHOD = /^ {2}(\w+)\(/gm;

function body(text, opening) {
  let i = opening;
  let depth = 0;
  do {
    if (text[i] === "{") depth += 1;
    else if (text[i] === "}") depth -= 1;
    i += 1;
  } while (i < text.length && depth > 0);
  return text.slice(opening, i);
}

const root = process.argv[2];
let hits = 0;

for (const f of readdirSync(root).filter((a) => a.endsWith(".mjs")).sort()) {
  const text = readFileSync(join(root, f), "utf8");
  for (const m of text.matchAll(METHOD)) {
    const b = body(text, text.indexOf("{", m.index + m[0].length));
    const writes = /\brecord\.\w+\s*=[^=]/.test(b);
    const returns = /\breturn\s+[^;\s]/.test(b);
    const kind = writes && returns ? "command+query" : writes ? "command" : returns ? "query" : "empty";
    if (writes && returns) hits += 1;
    console.log(`  ${f.padEnd(14)} ${m[1].padEnd(18)} writes=${writes} returns=${returns}  ${kind}`);
  }
}
console.log(`${root.padEnd(10)} methods that both write and return = ${hits}`);
```

```sh
node command-query-scan.mjs mixed
node command-query-scan.mjs separated
```

```
  shipment.mjs   depart             writes=true returns=true  command+query
mixed      methods that both write and return = 1
  shipment.mjs   departureBlocker   writes=false returns=true  query
  shipment.mjs   depart             writes=true returns=false  command
separated  methods that both write and return = 0
```

## Accepted Exceptions to the Separation

The separation cannot be applied absolutely. An operation that pops an item off a stack both
changes state and returns the item it removed; splitting it apart can open a race between the
two calls. In the same way, an operation that inserts a record and returns the generated
identifier is also mixed, and splitting it would require looking the identifier up again with
a second query.

Two conditions make the exception acceptable. The first is that the returned value is the
operation's **result** — the popped item, the generated identifier — not a recomputed state.
The second is that the call is not repeated for the purpose of reading. Both conditions were
violated in the mixed version above: the returned value was not the dispatch's result but a
re-evaluation of the conditions, and it was called again for the log line.

Naming is therefore part of the measure. `departureBlocker` asks a question, `depart` gives a
command; the two getting mixed usually starts with the name getting mixed too.

## Summary

- Command–query separation requires an operation to either change state and return no value,
  or return a value and change no state.
- The scan counts methods whose body both writes to the record and returns a value: the mixed
  design produced 1 finding, the separated design produced 0.
- The report-only code path changed 2 records' state in the mixed design and 0 records in the
  separated design.
- The log line wrote the opposite of the truth in the mixed design: `not_in_warehouse` showed
  up for the two dispatched shipments, because the state had already changed by the time the
  reason was asked.
- The exception is accepted when the returned value is the operation's result and the call is
  not repeated for reading; naming is the visible part of the separation.

## Next Step

In the separated design, the `WEIGHT_LIMIT` constant sits inside the shipment module, and so
does the dispatch rule. The two decisions live in the same file, but they do not change for
the same reason: the limit moves when the price list is renewed, the rule moves when
organizational policy changes. When a module holds more than one axis of change, two changes
coming from two separate axes touch the same files and break each other's tests. The next
lesson derives the file sets touched by changes on two axes, measures their intersection, and
shows how isolating the axes empties that intersection.
