Lesson 09 / 19
Tell, Don't Ask
Moving behavior into the data: comparing a design where four clients each build the same dispatch rule with a design that tells the object the decision, counting the number of files that know the rule's fields, measuring the clients' divergent result set, and finding the number of files edited when the rule changes.
Contents
In the Demeter lesson, chains got shorter, but the intermediate objects still returned
values: provinceDistrict() hands back a string, and the caller makes the decision. This
hides another form of the same problem. Every client that takes fields from an object and
decides by looking at those fields carries a copy of the rule inside itself. As the number
of copies grows, the rule no longer has a single definition.
Tell, don’t ask targets this repetition: the object that owns the data is told what to do, instead of having its data taken so the decision can be made outside it. Its measure is two numbers — how many files know the fields the rule depends on, and how many files repeat the same rule.
The Design That Writes the Rule Four Times
The dispatch rule consists of three conditions: the shipment must be in the warehouse, it must be paid, and its weight must not exceed the limit. Four clients each build this rule separately.
mkdir -p ask tell
// ask/record.mjs — shipments awaiting dispatch export const RECORDS = [ { 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 }, ];
// ask/web.mjs — the HTTP endpoint builds the rule itself export function dispatch(record) { if (record.state === "warehouse" && record.paid && record.weight <= 30) { return { httpStatus: 200, code: record.code }; } return { httpStatus: 409, code: record.code }; }
// ask/batch.mjs — the batch job rewrites the same rule export const selected = (records) => records .filter((r) => r.state === "warehouse" && r.paid && r.weight <= 30) .map((r) => r.code);
// ask/courier.mjs — the courier app never wrote the weight condition export const list = (records) => records.filter((r) => r.state === "warehouse" && r.paid).map((r) => r.code);
// ask/scheduled.mjs — the scheduled job writes the rule a third time export function scan(records) { const out = []; for (const r of records) { if (r.state === "warehouse" && r.paid && r.weight <= 30) out.push(r.code); } return out; }
The Design That Tells the Object the Decision
In the second design, the rule lives inside the shipment object. Clients do not read fields; they tell the object to depart and take back the result.
// tell/record.mjs — shipments awaiting dispatch export const RECORDS = [ { 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 }, ];
// tell/shipment.mjs — the rule lives inside the object, in one place 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" }; return { ok: true, reason: "departed" }; }, });
// tell/web.mjs — tells the object the decision export function dispatch(s) { return { httpStatus: s.depart().ok ? 200 : 409, code: s.code }; }
// tell/batch.mjs — gives the same command export const selected = (shipments) => shipments.filter((s) => s.depart().ok).map((s) => s.code);
// tell/courier.mjs — gives the same command; no chance to write the condition export const list = (shipments) => shipments.filter((s) => s.depart().ok).map((s) => s.code);
// tell/scheduled.mjs — gives the same command export function scan(shipments) { const out = []; for (const s of shipments) if (s.depart().ok) out.push(s.code); return out; }
Counting the Repetition
The first measure is how many files know the field names the rule depends on. A file is counted as carrying a copy of the rule if it knows two or more of these fields; the data file is excluded from this count.
// rule-count.mjs — counts how many files know the fields the rule depends on import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; const FIELDS = ["state", "paid", "weight"]; const root = process.argv[2]; let filesKnowing = 0; let repeats = 0; for (const d of readdirSync(root).filter((a) => a.endsWith(".mjs")).sort()) { const text = readFileSync(join(root, d), "utf8").replace(/^\/\/.*$/gm, ""); const known = FIELDS.filter((a) => new RegExp(`\\b${a}\\b`).test(text)); if (known.length > 0) filesKnowing += 1; if (known.length >= 2 && d !== "record.mjs") repeats += 1; console.log(` ${d.padEnd(12)} known fields=${known.length} ${known.join(" ")}`); } console.log(`${root.padEnd(6)} files knowing the fields=${filesKnowing}, files repeating the rule=${repeats}`);
node rule-count.mjs ask node rule-count.mjs tell
batch.mjs known fields=3 state paid weight courier.mjs known fields=2 state paid record.mjs known fields=3 state paid weight scheduled.mjs known fields=3 state paid weight web.mjs known fields=3 state paid weight ask files knowing the fields=5, files repeating the rule=4 batch.mjs known fields=0 courier.mjs known fields=0 record.mjs known fields=3 state paid weight scheduled.mjs known fields=0 shipment.mjs known fields=3 state paid weight web.mjs known fields=0 tell files knowing the fields=2, files repeating the rule=1
Four files repeat the rule in the first design; one in the second. The scan also showed where the rule had already cracked: the courier module knows only two of the three fields.
The Divergent Result Set
The runtime counterpart of drift between copies is that clients working on the same data produce different lists. The driver script runs all four clients on the same five shipments and counts how many distinct results come out.
// setup.mjs — four clients work with the same data, results are compared const root = process.argv[2]; const { RECORDS } = await import(`./${root}/record.mjs`); const { dispatch } = await import(`./${root}/web.mjs`); const { selected } = await import(`./${root}/batch.mjs`); const { list } = await import(`./${root}/courier.mjs`); const { scan } = await import(`./${root}/scheduled.mjs`); const input = root.startsWith("tell") ? RECORDS.map((await import(`./${root}/shipment.mjs`)).shipment) : RECORDS; const results = { web: input.filter((r) => dispatch(r).httpStatus === 200).map((r) => r.code), batch: selected(input), courier: list(input), scheduled: scan(input), }; for (const [name, arr] of Object.entries(results)) console.log(`${name.padEnd(9)} ${arr.join(" ")}`); const distinct = new Set(Object.values(results).map((l) => l.join(" "))); console.log(`${root}: distinct result set among clients = ${distinct.size}`);
node setup.mjs ask node setup.mjs tell
web GN-1 GN-5 batch GN-1 GN-5 courier GN-1 GN-2 GN-5 scheduled GN-1 GN-5 ask: distinct result set among clients = 2 web GN-1 GN-5 batch GN-1 GN-5 courier GN-1 GN-5 scheduled GN-1 GN-5 tell: distinct result set among clients = 1
GN-2, at forty-five kilograms, shows up on the courier’s list and nowhere else. In the second design there is no room for this kind of drift, because there are no four separate bodies of code to write the condition in.
When the Rule Changes
The weight limit drops from 30 to 20. Both trees are copied and the change is applied to
each. The in-place edit is given a backup extension; GNU and BSD sed behave the same way
with this form.
cp -r ask ask-new cp -r tell tell-new sed -i.y 's/weight <= 30/weight <= 20/' ask-new/*.mjs sed -i.y 's/WEIGHT_LIMIT = 30/WEIGHT_LIMIT = 20/' tell-new/shipment.mjs rm -f ask-new/*.y tell-new/*.y node setup.mjs ask-new node setup.mjs tell-new for k in ask tell; do echo "$k: edited files = $(diff -rq $k $k-new | grep -c '^Files')"; done
web GN-1 batch GN-1 courier GN-1 GN-2 GN-5 scheduled GN-1 ask-new: distinct result set among clients = 2 web GN-1 batch GN-1 courier GN-1 scheduled GN-1 tell-new: distinct result set among clients = 1 ask: edited files = 3 tell: edited files = 1
Three files against one. Notice also that the drift grew: the courier’s list used to carry only the extra GN-2, and once the limit dropped, GN-5 was added too. Once the rule’s copies diverge, the divergence is permanent; it grows with every change to the rule.
The Limit of the Principle
Tell, don’t ask does not forbid every field read. Handing a field out becomes a problem only when a decision about that field is made outside the object. Reading the shipment code to print it in a report is not a decision; looking at the shape of the code and deciding whether the shipment is domestic is.
Overapplying the principle also produces a measurable cost. Adding a separate command method to the object for every question grows the interface, and the bloat measured in the Interface Segregation Principle lesson comes back. The criterion is not the number of methods, but the number of files repeating the rule: if that number is greater than one, the behavior sits in the wrong place.
Summary
- The tell, don’t ask principle asks that the work behind a decision be told to the object that owns the data, instead of taking its fields and deciding outside it.
- The number of files that know the rule’s fields dropped from 5 to 2, and the number of files repeating the rule dropped from 4 to 1.
- Repetition produced drift: the four clients gave 2 distinct result sets on the same data, while the single-definition version produced 1.
- When the weight limit changed, 3 files were edited in the repeating design and 1 in the single-definition design; the drift grew along with the rule change.
- The principle limits deciding on a field from outside, not reading the field itself; the criterion is the number of files repeating the rule.
Next Step
depart was named like a command, but it actually only returns a result: the shipment’s
state does not change. A real dispatch operation should change the state too. What happens
when the same method both changes state and returns a value? Does calling the method twice
give the same result, does an extra call made just to write a log line change the system’s
state? The next lesson demonstrates, with a runtime example, the surprise produced by an
operation that both changes state and returns a value, and establishes the measure for
separating the two responsibilities.
To keep your progress and take notes, Log in
My notes
Log in to take notes.