Skip to content
academia.sh

Lesson 17 / 19

Command and Query Responsibility Segregation

Separating the write and read models: producing the operation dashboard from the aggregate roots and from a separate read model, comparing the number of objects visited across three data sizes, measuring how many commands behind the read model falls and how many answers come out stale, and the cost in added files, lines, and mapping writes.

Contents

Every measure up to this point pointed toward writing: which decisions an arrival record passed through, which ports it went out through. The operation desk’s real work, though, is reading: how many packages are waiting at which transfer point, how many of them are behind their route. If the answer to these questions is sought through the aggregate roots, every delivery and every leg of its route is walked for every answer; the walk grows with the number of deliveries.

Command and query responsibility segregation gives these two directions separate models. The write side is built to preserve invariants — aggregate roots, consistency boundaries, decisions. The read side is built to match the shape of the question: the rows a query asks for are kept ready.

This segregation is a different thing from the command–query separation introduced in the Programming Fundamentals course: there, the test was whether a single method either changes state or returns a value — a single method’s signature. What is separated here is not a method but a model: keeping the same data in two separate structures.

The Write Model

The write side continues the previous lessons’ aggregate root. Every delivery has a route, the points it has passed, and the number of points it is expected to have passed by now.

// write-model.mjs — delivery aggregates and the single command: record arrival
const POINTS = ["34", "06", "35", "01", "16"];
const DELIVERIES = new Map();

export function seed(count) {
  DELIVERIES.clear();
  let x = 7;
  for (let i = 1; i <= count; i += 1) {
    x = (x * 48271 + 11) % 2147483647;
    const length = 2 + (x % 4);
    const start = x % POINTS.length;
    const route = Array.from({ length }, (_, j) => POINTS[(start + j) % POINTS.length]);
    const progress = x % length;
    DELIVERIES.set(`T${i}`, {
      id: `T${i}`, route, passed: route.slice(0, progress), expected: (x % 2) + progress,
    });
  }
  return DELIVERIES.size;
}

export const writeModel = {
  all: () => [...DELIVERIES.values()].map((t) => structuredClone(t)),
  find: (id) => structuredClone(DELIVERIES.get(id) ?? null),
};

export function arrivalCommand(id) {
  const t = DELIVERIES.get(id);
  if (t === undefined || t.passed.length >= t.route.length) return null;
  const before = structuredClone(t);
  t.passed.push(t.route[t.passed.length]);
  return { before, after: structuredClone(t) };
}

Seeding uses a fixed generator; the same delivery count gives the same routes on every run. The command returns both the before and after of the change together, because the read side needs to know which row changed.

Producing the Dashboard from the Write Model

The dashboard query reads every delivery, walks its route and its passed points, finds the next point, and increments that point’s row.

// dashboard-from-write.mjs — the dashboard query over the write model: every aggregate and every route leg is walked
export function dashboardFromWrite(writeModel) {
  let visited = 0;
  const row = new Map();
  for (const t of writeModel.all()) {
    visited += 1;
    for (const _ of t.route) visited += 1;
    for (const _ of t.passed) visited += 1;
    const next = t.route[t.passed.length];
    if (next === undefined) continue;
    const s = row.get(next) ?? { point: next, waiting: 0, behind: 0 };
    s.waiting += 1;
    if (t.passed.length < t.expected) s.behind += 1;
    row.set(next, s);
  }
  return { rows: [...row.values()].sort((a, b) => a.point.localeCompare(b.point)), visited };
}

The visited counter turns the claim into a measure: every aggregate root, every route leg, and every passed point counts as one visit.

The Read Model

The read model consists of five rows — one per transfer point. The rows are set up once, then updated only by the deltas commands produce. Unapplied deltas wait in a queue; the queue’s length is the model’s lag.

// read-model.mjs — the read model keeping the dashboard rows ready, and a queue of unapplied updates
const ROW = new Map();
const PENDING = [];

export const readModel = {
  setup(rows) {
    ROW.clear(); PENDING.length = 0;
    for (const r of rows) ROW.set(r.point, { ...r });
  },
  publish(delta) { PENDING.push(delta); },
  lag: () => PENDING.length,
  advance(count = PENDING.length) {
    const applied = PENDING.splice(0, count);
    for (const delta of applied) {
      for (const [point, waiting, behind] of delta) {
        const s = ROW.get(point) ?? { point, waiting: 0, behind: 0 };
        s.waiting += waiting; s.behind += behind;
        ROW.set(point, s);
      }
    }
    return applied.length;
  },
  dashboard() {
    let visited = 0;
    const rows = [];
    for (const s of ROW.values()) { visited += 1; if (s.waiting > 0) rows.push({ ...s }); }
    return { rows: rows.sort((a, b) => a.point.localeCompare(b.point)), visited };
  },
};

The single piece connecting the two models is a mapping: it computes which rows a command changes, and in which direction.

// projection.mjs — the mapping that computes which dashboard rows a command changes
export function projection({ before, after }) {
  const oldPoint = before.route[before.passed.length], newPoint = after.route[after.passed.length];
  const delta = [];
  if (oldPoint !== undefined) delta.push([oldPoint, -1, before.passed.length < before.expected ? -1 : 0]);
  if (newPoint !== undefined) delta.push([newPoint, 1, after.passed.length < after.expected ? 1 : 0]);
  return delta;
}

Measurement

The measurement counts three things separately. The first is visits: the objects the two paths walk, across three delivery counts. The second is staleness: across twenty-four commands, how many commands behind the read model falls, and how many queries give an answer that differs from the write model. The third is cost: the number of added files, lines, and mapping writes.

// dashboard-count.mjs — objects visited for the query, staleness lag, and the projection mapping count
import { readFileSync } from "node:fs";
import { seed, writeModel, arrivalCommand } from "./write-model.mjs";
import { dashboardFromWrite } from "./dashboard-from-write.mjs";
import { readModel } from "./read-model.mjs";
import { projection } from "./projection.mjs";

for (const n of [50, 200, 800]) {
  seed(n);
  const w = dashboardFromWrite(writeModel);
  readModel.setup(w.rows);
  const r = readModel.dashboard();
  const equal = JSON.stringify(w.rows) === JSON.stringify(r.rows);
  console.log(`deliveries = ${String(n).padStart(3)}  visited from write model = ${String(w.visited).padStart(4)}` +
    `, visited from read model = ${r.visited}, rows equal = ${equal}`);
}

seed(200);
readModel.setup(dashboardFromWrite(writeModel).rows);
console.log(`starting dashboard = ${JSON.stringify(readModel.dashboard().rows)}`);

const COMMANDS = Array.from({ length: 24 }, (_, i) => `T${i * 7 + 3}`);
const ADVANCE_INTERVAL = 6;
let stale = 0, maxLag = 0, mappingCount = 0, commandCount = 0;
for (const [index, id] of COMMANDS.entries()) {
  const result = arrivalCommand(id);
  if (result === null) continue;
  commandCount += 1;
  const delta = projection(result);
  mappingCount += delta.length;
  readModel.publish(delta);
  if ((index + 1) % ADVANCE_INTERVAL === 0) readModel.advance();
  maxLag = Math.max(maxLag, readModel.lag());
  const write = JSON.stringify(dashboardFromWrite(writeModel).rows);
  const read = JSON.stringify(readModel.dashboard().rows);
  if (write !== read) stale += 1;
}
console.log(`commands = ${commandCount}, projection mapping writes = ${mappingCount}`);
console.log(`stale answers = ${stale} / ${commandCount}, max lag = ${maxLag} commands`);
readModel.advance();
const finalWrite = JSON.stringify(dashboardFromWrite(writeModel).rows);
const finalRead = JSON.stringify(readModel.dashboard().rows);
console.log(`equal after queue drained = ${finalWrite === finalRead}, lag = ${readModel.lag()}`);
console.log(`final dashboard = ${finalRead}`);

const lineCount = (d) => readFileSync(d, "utf8").split("\n").filter((s) => s.trim() !== "").length;
for (const [name, files] of [["single model", ["write-model.mjs", "dashboard-from-write.mjs"]],
  ["added for the read model", ["read-model.mjs", "projection.mjs"]]]) {
  console.log(`${name.padEnd(26)} ${files.length} files, ${files.reduce((s, d) => s + lineCount(d), 0)} lines`);
}
node dashboard-count.mjs
deliveries =  50  visited from write model =  309, visited from read model = 5, rows equal = true
deliveries = 200  visited from write model = 1220, visited from read model = 5, rows equal = true
deliveries = 800  visited from write model = 4621, visited from read model = 5, rows equal = true
starting dashboard = [{"point":"01","waiting":45,"behind":29},{"point":"06","waiting":46,"behind":25},{"point":"16","waiting":32,"behind":19},{"point":"34","waiting":40,"behind":15},{"point":"35","waiting":37,"behind":24}]
commands = 24, projection mapping writes = 44
stale answers = 20 / 24, max lag = 5 commands
equal after queue drained = true, lag = 0
final dashboard = [{"point":"01","waiting":43,"behind":27},{"point":"06","waiting":39,"behind":18},{"point":"16","waiting":29,"behind":17},{"point":"34","waiting":38,"behind":12},{"point":"35","waiting":47,"behind":23}]
single model               2 files, 46 lines
added for the read model   2 files, 36 lines

Reading the Numbers

The visit counts say the same thing at all three sizes. Producing the dashboard through the write model visits 309 objects at 50 deliveries, 1220 at 200, 4621 at 800; the ratio is roughly 6 per delivery, because each delivery itself, its route legs, and its passed points are each read separately. The same dashboard through the read model visits 5 objects at every size. This number depends not on the delivery count but on the transfer point count; it stays constant as the data grows. At all three sizes, the rows the two paths produce came out equal, so the read model is a correct projection.

The staleness numbers show the cost. Across twenty-four commands, the queue was drained every six commands; the read model fell at most 5 commands behind, and 20 of the 24 queries gave an answer that differed from the write model. These 20 answers are not wrong, they are stale: the number of packages waiting at a transfer point shows a state up to five commands old. Once the queue was drained, the two models matched again and the lag dropped to 0.

The cost is two files and 36 lines. Add to that the per-command mapping writes: 24 commands produced 44 lines of updates, an average of 1.8 per command. The segregation drops the visit count from 6n to a constant 5 while loading the write path this much more heavily.

Lag Is a Domain Decision

A five-command lag looks like a technical setting, but the domain expert makes the call. On the transfer center’s dashboard, a five-command lag is not a problem: the counter changes within minutes anyway. The same lag is not acceptable in the decision that prevents a package from being recorded at the same point twice. For this reason, the segregation is not applied as “every query from the read model”; how much lag a given query can carry is context-dependent.

The general measures of read-model freshness, invalidation, and the freshness window were covered in the Caching, Queues and Asynchronous Processing course; the only thing measured here is the lag the segregation itself introduces.

When It Does Not Apply

The gain depends on two conditions. First, the query’s visit count must grow with the data size: a query that reads a single delivery by identity already visits 1 object in the write model, and the read model gains it nothing. Second, the query’s shape must be stable; if the query changes every month, the mapping changes every month too, and both files stay perpetually open.

The third boundary is consistency. If a domain rule depends on a query’s answer — something like “a point can hold at most a hundred waiting packages” — that rule cannot be read from the read model, because the read model runs behind. Such a rule has to stay inside the write side’s consistency boundary.

Summary

  • Command and query responsibility segregation keeps two separate models for write and read; command–query separation says a single method either changes state or returns a value.
  • The dashboard visited 309, 1220, and 4621 objects through the write model at 50, 200, and 800 deliveries; through the read model it visited 5 objects at every size, and the rows came out equal.
  • The read model fell at most 5 commands behind; 20 of 24 queries gave a stale answer, and the two models matched once the queue was drained.
  • The cost is 2 files, 36 lines, and an average of 1.8 mapping writes per command; the segregation makes the read path cheaper while loading the write path more heavily.
  • How much lag is acceptable is a domain decision; if a domain rule depends on a query’s answer, that rule has to stay inside the write side’s consistency boundary.

Next Step

The read model was built from the deltas commands produce, but the deltas themselves were discarded once applied. This leaves a question that cannot be asked of the write side: how many times was a delivery’s address corrected, how many retries happened at which point, in what order did things happen between going out for delivery and being delivered? Because the record keeps only the current state, the model has no answer to these questions. The next lesson takes up a persistence form that stores not state but the event sequence: it tests whether state can be rebuilt from events, counts how many events a rebuild reads, and measures where that count lands once a snapshot is taken.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close