Skip to content
academia.sh

Lesson 15 / 18

Command and Query Separation

Scaling the read and write paths with separate models: the tracking query touching eight records in the single model and one in the projection, the command climbing from three touches to five, comparing the two layouts' node counts under read and write multipliers, and the read store's inability to escape K01's write/read ratio.

Contents

The previous topic pulled the read path forward by a layer: the cache kept most requests from reaching the store — the requests reaching the store/s row fell to 140.23 — and bounded the staleness of the ones that did with a window. But the cache stores a copy of the store’s response; every miss still goes to the same model, which arranges the shipment record, the state events, and the link between them to preserve the write side’s invariants. The read path borrows a model built for writing and pays its cost on every miss.

This topic stops borrowing. Its question: does keeping the same data in two separate structures let the read and write paths scale separately, and if so, past what ratio.

Two Separate Separations

Two similarly named concepts exist and are not interchangeable. Command–query separation, from the Programming Fundamentals course, concerns a method’s signature: it either changes state or returns a value, never both. Command and query responsibility segregation separates not the method but the model — the same data kept in two separate structures.

The second pattern was built in the Domain-Driven Design course and measured there: the objects the dashboard query walked through the write model grew with data volume, while the count through the read model stayed constant. That lesson also set a boundary — a query reading a single record by identity walks a single object in the write model too, and the separation gains it nothing. The tracking query looks exactly like that: a single tracking number. The measurement starts by testing whether that resemblance is misleading.

Access Pattern

The decision comes with a pattern: a single read by tracking number, writing the state event that arrives from the carrier, and a periodic batch scan by seller. The first two are this lesson’s subject.

The tracking response’s body was defined in K01 (V5, 480 bytes): state, zone, update time, and the last three route steps. Those steps sit not in the shipment record but in the event records, so assembling the response through the write model requires reading the shipment record and all of that shipment’s state events — 7 events under K01’s V4 assumption.

The apparatus is an in-process store model: no real database, disk, or network. Records sit in memory, and every access increments the record count it touches. The measure is record touches, not duration, since duration depends on the machine and touch count does not.

// separation/store.mjs — in-process store model. No real database: records sit in
// memory and every access counts the records it touches. Measured: touches, not duration.
const POINTS = ["34", "06", "35", "01", "16"];

export function createStore() {
  return { shipments: new Map(), events: new Map(), projections: new Map(), counters: { reads: 0, writes: 0 } };
}

function buildResponse(g, events) {
  return { id: g.id, state: g.state, zone: g.zone, at: g.at, lastSteps: events.slice(-3).map((o) => o.point) };
}

export function seed(d, count, eventsPerShipment) {
  let x = 7;
  const next = () => (x = (x * 48271 + 11) % 2147483647);
  for (let i = 1; i <= count; i += 1) {
    const id = `G${i}`;
    const g = { id, zone: POINTS[next() % POINTS.length], state: "in-transit", at: eventsPerShipment };
    const events = Array.from({ length: eventsPerShipment }, (_, k) => ({ id, seq: k + 1, point: POINTS[next() % POINTS.length] }));
    d.shipments.set(id, g);
    d.events.set(id, events);
    d.projections.set(id, buildResponse(g, events));
  }
  return d.shipments.size;
}

// Single model: the tracking response is assembled from the shipment record and its events.
export function trackSingleModel(d, id) {
  const g = d.shipments.get(id);
  d.counters.reads += 1;
  const events = d.events.get(id);
  d.counters.reads += events.length;
  return buildResponse(g, events);
}

// Split model: the tracking response is read from a single projection row waiting ready.
export function trackFromProjection(d, id) {
  d.counters.reads += 1;
  return d.projections.get(id);
}

// Command: an event is appended, the shipment record is updated with read-modify-write;
// in the split layout the projection row is also refreshed with read-modify-write.
export function command(d, id, point, writeProjection) {
  const events = d.events.get(id);
  events.push({ id, seq: events.length + 1, point });
  d.counters.writes += 1;
  const g = d.shipments.get(id);
  d.counters.reads += 1;
  g.at = events.length;
  d.counters.writes += 1;
  if (writeProjection !== true) return;
  const old = d.projections.get(id);
  d.counters.reads += 1;
  d.projections.set(id, { ...old, at: g.at, lastSteps: events.slice(-3).map((o) => o.point) });
  d.counters.writes += 1;
}

Projection, as defined in the Domain-Driven Design course, is a read model derived from the changes the write side produces. Here the projection row is the tracking response itself.

Measurement and Computed Values

The measurement runs in two steps: the model runs and the touches per query and per command are counted — deterministic numbers — then those numbers are applied to K01’s rates, and a node count comes out. That count needs a capacity.

OY1 — the record touches per second a store node sustains: 100. This is this topic’s own assumption, not added to K01’s table; its rationale is that a division needs a unit, and the number’s absolute value is not a measurement. Its sensitivity has a special form: taking 200 halves every node count, but the two layouts’ ratio stays unchanged, since capacity is the same divisor on both sides.

// separation/measure.mjs — the layouts' touches per query and command are measured from the
// model, then applied to K01's rates. All numbers are deterministic; a computed value in class.
import { createStore, seed, trackSingleModel, trackFromProjection, command } from "./store.mjs";

const V4 = 7;          // K01 assumption: state events per shipment
const READ = 41.67;    // K01 computed value: reads behind cache/s
const WRITE = 97.22;   // K01 computed value: peak write requests/s
const OY1 = 100;       // this topic's assumption: record touches/s a node sustains

const N = 200;
const d = createStore();
seed(d, N, V4);

const ids = [...d.shipments.keys()];
d.counters.reads = 0; d.counters.writes = 0;
const singleResponses = ids.map((id) => trackSingleModel(d, id));
const singleQuery = d.counters.reads / N;

d.counters.reads = 0; d.counters.writes = 0;
const projResponses = ids.map((id) => trackFromProjection(d, id));
const projQuery = d.counters.reads / N;
const equal = JSON.stringify(singleResponses) === JSON.stringify(projResponses);

d.counters.reads = 0; d.counters.writes = 0;
for (const id of ids) command(d, id, "16", false);
const singleCommand = { reads: d.counters.reads / N, writes: d.counters.writes / N };

d.counters.reads = 0; d.counters.writes = 0;
for (const id of ids) command(d, id, "01", true);
const splitCommand = { reads: d.counters.reads / N, writes: d.counters.writes / N };

const b = (x, n = 2) => x.toFixed(n);
console.log(`model: ${N} shipments, ${V4} events per shipment (K01 V4)`);
console.log(`tracking query / single model    = ${singleQuery} record touches`);
console.log(`tracking query / from projection  = ${projQuery} record touches, two responses equal = ${equal}`);
console.log(`command / single model            = ${singleCommand.reads + singleCommand.writes} touches ` +
  `(${singleCommand.reads} reads, ${singleCommand.writes} writes)`);
console.log(`command / split model             = ${splitCommand.reads + splitCommand.writes} touches ` +
  `(${splitCommand.reads} reads, ${splitCommand.writes} writes)`);

const single = (r, w) => READ * r * singleQuery + WRITE * w * (singleCommand.reads + singleCommand.writes);
const splitWrite = (w) => WRITE * w * (singleCommand.reads + singleCommand.writes);
const splitRead = (r, w) => READ * r * projQuery + WRITE * w * (splitCommand.reads + splitCommand.writes - singleCommand.reads - singleCommand.writes);
const nodes = (load) => Math.ceil(load / OY1);

console.log(`\nK01: reads behind cache ${READ}/s, peak writes ${WRITE}/s, ratio ${b(WRITE / READ)}`);
console.log(`node capacity OY1 = ${OY1} touches/s (this topic's assumption)\n`);
console.log(`${"read x".padStart(8)}${"write x".padStart(8)}${"single touches/s".padStart(18)}` +
  `${"single nodes".padStart(14)}${"split w+r touches/s".padStart(21)}${"split nodes".padStart(13)}`);
for (const [r, w] of [[1, 1], [2, 1], [4, 1], [1, 2], [1, 4], [4, 4]]) {
  const t = single(r, w), sw = splitWrite(w), sr = splitRead(r, w);
  console.log(`${String(r).padStart(8)}${String(w).padStart(8)}${b(t).padStart(18)}` +
    `${String(nodes(t)).padStart(14)}${`${b(sw)} + ${b(sr)}`.padStart(21)}` +
    `${`${nodes(sw)} + ${nodes(sr)} = ${nodes(sw) + nodes(sr)}`.padStart(13)}`);
}

const gain = (r, w) => nodes(single(r, w)) - nodes(splitWrite(w)) - nodes(splitRead(r, w));
let firstLoss = null;
for (let w = 1; w <= 20; w += 1) if (gain(1, w) < 0) { firstLoss = w; break; }
console.log(`\nnode gain from the separation: baseline ${gain(1, 1)}, at read x4 ${gain(4, 1)}, ` +
  `at write x4 ${gain(1, 4)}`);
console.log(`smallest write multiplier at which the separation raises the node count = ${firstLoss}`);

const projUpdate = splitCommand.reads + splitCommand.writes - singleCommand.reads - singleCommand.writes;
console.log(`\nrequests the read store sees: query ${b(READ)}/s, projection update ` +
  `${b(WRITE)}/s -> write/read ratio ${b(WRITE / READ)} (K01's ratio)`);
console.log(`touches the read store sees: ${b(READ * projQuery)} + ${b(WRITE * projUpdate)} ` +
  `-> ratio ${b((WRITE * projUpdate) / (READ * projQuery))}`);
model: 200 shipments, 7 events per shipment (K01 V4)
tracking query / single model    = 8 record touches
tracking query / from projection  = 1 record touches, two responses equal = true
command / single model            = 3 touches (1 reads, 2 writes)
command / split model             = 5 touches (2 reads, 3 writes)

K01: reads behind cache 41.67/s, peak writes 97.22/s, ratio 2.33
node capacity OY1 = 100 touches/s (this topic's assumption)

  read x write x  single touches/s  single nodes  split w+r touches/s  split nodes
       1       1            625.02             7      291.66 + 236.11    3 + 3 = 6
       2       1            958.38            10      291.66 + 277.78    3 + 3 = 6
       4       1           1625.10            17      291.66 + 361.12    3 + 4 = 7
       1       2            916.68            10      583.32 + 430.55   6 + 5 = 11
       1       4           1500.00            15     1166.64 + 819.43  12 + 9 = 21
       4       4           2500.08            26     1166.64 + 944.44 12 + 10 = 22

node gain from the separation: baseline 1, at read x4 10, at write x4 -6
smallest write multiplier at which the separation raises the node count = 2

requests the read store sees: query 41.67/s, projection update 97.22/s -> write/read ratio 2.33 (K01's ratio)
touches the read store sees: 41.67 + 194.44 -> ratio 4.67

Reading the Numbers

The first three lines show the resemblance is misleading. The tracking query arrives with a single identity, but through the write model it touches 8 records: one shipment record and seven event records. Through the projection it touches 1, and the two paths’ responses are equal. The separation brings no gain not when a query arrives with a single identity, but when the response comes from a single record; the tracking response does not.

The command rows carry the cost. In the single model, a state event costs 3 touches: the event is appended, the shipment record read and written. In the split layout the projection row must also be read and written, and the command climbs to 5 touches — the write path grows to 1.67 times.

The node table compares the two paths at K01’s rates. At baseline, the single model wants 7 nodes at 625.02 touches/s; the split layout wants 6, with 3 write and 3 read nodes — a gain of one node, not enough to justify the separation alone.

The real difference is in the direction of growth. At read x4, the single model climbs to 17 nodes while the split layout stays at 7, a gain of 10, because read growth in the split layout only grows the projection queries, each 1 touch. At write x4 the table reverses: 15 nodes against 21, a loss of 6, and the threshold arrives early — the separation is already costly once the write multiplier reaches 2. The separation is a bet that reads will grow faster than writes.

The Ratio Does Not Disappear, It Moves

The last two lines measure the part of the pattern most often misread. K01 had found the write/read ratio at the store to be 2.33; a system that looks read-heavy was write-heavy where the store saw it. The separation does not escape that ratio: the read store sees 41.67 queries a second and 97.22 projection updates, so in request terms the ratio is still 2.33. In touches it sharpens further — 41.67 against 194.44, a ratio of 4.67. The store named “read model” is twice as write-heavy as the original.

The separation gives not a corrected ratio but a separated cost per touch: read-path touches drop from 8 to 1, write-path touches climb from 3 to 5. K01’s 138.89 requests in the requests reaching the store/s row do not disappear — they split across two stores, 97.22 to the write store and 41.67 to the read store. Total touches drop from 625.02 to 527.77, a 1.18× factor. All of these numbers belong to the computed-value class; the only assumption is OY1, and it only does the dividing.

The New Question the Separation Raises

In the split layout, the projection row was updated together with the command, so it stayed fresh throughout — making the model look better than it should. Writing to two stores at once requires them to sit inside a single consistency boundary; to scale separately they cannot share that boundary, so the projection is updated after the command, opening a window in between. How long that window stays open, and why the read store needs to be rebuildable, depend on what the projection is derived from.

Summary

  • Command–query separation is about a method’s signature; command and query responsibility segregation is about the model. The second was built in the Domain-Driven Design course and is treated here as a scaling tool.
  • Though the tracking query arrives with a single identity, its response does not come from a single record: 8 record touches through the write model, 1 through the projection, and the two responses are equal.
  • The cost sits in the write path: the command climbs from 3 touches to 5, a 1.67× factor.
  • With OY1 = 100 touches/s capacity, nodes at baseline drop from 7 to 6; at read x4 it is 17 against 7, at write x4 it is 15 against 21. The separation turns to a loss once the write multiplier reaches 2.
  • K01’s 2.33 does not disappear, it moves: the read store’s request ratio is also 2.33, its touch ratio is 4.67. The separation changes not the ratio but the cost per touch.

Next Step

Updating the projection together with the command made this lesson’s measurement easier, but it defeats the point of the separation: two stores in the same consistency boundary cannot scale separately. If the projection is updated after the command, at its own pace, what it is derived from must be settled. The shipment record holds only the current state, so it cannot be the source — a projection row has to know which change brought the record to that state. The next lesson takes up storing the change itself as a record: which read an append-only log removes from the write path, the fact that K01 never asked how many operations one write request turns into at the store, and what the event becoming the source-of-truth record adds to daily data growth and stored data rows.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close