Lesson 08 / 19
Law of Demeter
Limiting chained access: writing a scan that counts access chain depth, comparing the finding count in two designs that produce the same five outputs, and measuring how many files get edited when the address record is restructured.
Contents
The cohesion measure looked at whether a module touches its own names. Once modules start passing objects to each other, a new question appears: when a function reaches through an object it received, into a second object, and from there into a third field, how many separate structures does it have to know about? This dependency shows up neither in the import graph nor in the cohesion measure, because there is no new import and no new module-level name involved.
The law of Demeter limits this reach. A function should call methods only on: its own
object, the parameters it received, the objects it constructs itself, and its direct parts.
It is summarized as “talk to your neighbor, not your neighbor’s neighbor.” Its measurable
counterpart is the depth of the access chain: shipment.code is one step,
shipment.recipient.address.province is three steps, and it creates a dependency on the
shape of the two structures in between.
The Chained Design
The record is three levels deep, and five separate clients reach into it.
mkdir -p chained short
// chained/record.mjs — nested shipment record export const RECORD = { code: "GN-000001", recipient: { name: "A. Yilmaz", address: { province: "Ankara", district: "Cankaya", postalCode: "06500" } }, tariff: { name: "weight", tier: { max: 5, fee: 6400 } }, };
// chained/label.mjs — reaches two steps into the recipient record export const label = (shipment) => `${shipment.recipient.name} / ${shipment.recipient.address.province} ${shipment.recipient.address.postalCode}`;
// chained/fee.mjs — reaches into the tariff's tier and into the address const ZONE = { "34": 100, "06": 115, "65": 140 }; export const fee = (shipment) => Math.round((shipment.tariff.tier.fee * (ZONE[shipment.recipient.address.postalCode.slice(0, 2)] ?? 160)) / 100);
// chained/report.mjs — reads province and district separately export const report = (shipment) => `${shipment.code} ${shipment.recipient.address.province}/${shipment.recipient.address.district} ${shipment.tariff.name}`;
// chained/notification.mjs — the notification text walks the same chain export const notification = (shipment) => `${shipment.code} departed for ${shipment.recipient.address.province}`;
// chained/branch.mjs — delivery branch selection export const branch = (shipment) => `${shipment.recipient.address.province}-${shipment.recipient.address.district} branch`;
// chained/setup.mjs — the output of five clients import { RECORD } from "./record.mjs"; import { label } from "./label.mjs"; import { fee } from "./fee.mjs"; import { report } from "./report.mjs"; import { notification } from "./notification.mjs"; import { branch } from "./branch.mjs"; for (const f of [label, fee, report, notification, branch]) console.log(f(RECORD));
node chained/setup.mjs
A. Yilmaz / Ankara 06500 7360 GN-000001 Ankara/Cankaya weight GN-000001 departed for Ankara Ankara-Cankaya branch
The Chain Scan
The script extracts the access chains in each file, counts their depth, and reports the ones
longer than two steps as findings. Language and runtime objects (such as Math, JSON,
console) do not count as a field chain; the composition root, setup.mjs, is also outside
the scan.
// chain-scan.mjs — counts access chain depth; every chain longer than two steps is a finding import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; const CHAIN = /\b[a-zA-Z_]\w*(?:\.\w+)+/g; // Language and runtime objects do not count as a field chain. const EXCLUDED = ["Math", "Object", "String", "Number", "JSON", "console", "process", "Array"]; const root = process.argv[2]; let totalFindings = 0; let deepest = 0; for (const d of readdirSync(root).filter((a) => a.endsWith(".mjs")).sort()) { if (d === "setup.mjs") continue; const text = readFileSync(join(root, d), "utf8").replace(/^\/\/.*$/gm, ""); const chains = [...text.matchAll(CHAIN)] .map((m) => m[0]) .filter((z) => !EXCLUDED.includes(z.split(".")[0])); const depths = chains.map((z) => z.split(".").length - 1); const findings = chains.filter((z) => z.split(".").length - 1 >= 2); const localDeepest = depths.length === 0 ? 0 : Math.max(...depths); totalFindings += findings.length; deepest = Math.max(deepest, localDeepest); console.log(` ${d.padEnd(14)} chains=${chains.length} deepest=${localDeepest}` + ` findings=${findings.length}${findings.length > 0 ? " " + findings.join(" ") : ""}`); } console.log(`${root.padEnd(9)} total findings=${totalFindings} deepest chain=${deepest}`);
node chain-scan.mjs chained
branch.mjs chains=2 deepest=3 findings=2 shipment.recipient.address.province shipment.recipient.address.district fee.mjs chains=2 deepest=4 findings=2 shipment.tariff.tier.fee shipment.recipient.address.postalCode.slice label.mjs chains=3 deepest=3 findings=3 shipment.recipient.name shipment.recipient.address.province shipment.recipient.address.postalCode notification.mjs chains=2 deepest=3 findings=1 shipment.recipient.address.province record.mjs chains=0 deepest=0 findings=0 report.mjs chains=4 deepest=3 findings=3 shipment.recipient.address.province shipment.recipient.address.district shipment.tariff.name chained total findings=11 deepest chain=4
Eleven findings, with the deepest chain at four steps. All five clients know the address record’s field names.
Shortening the Chain
The way to shorten the chain is to give the intermediate objects behavior. Only one module knows the shape of the address record; the rest ask it for a result.
// short/record.mjs — the same nested record export const RECORD = { code: "GN-000001", recipient: { name: "A. Yilmaz", address: { province: "Ankara", district: "Cankaya", postalCode: "06500" } }, tariff: { name: "weight", tier: { max: 5, fee: 6400 } }, };
// short/address.mjs — only this module knows the shape of the address record export const address = (record) => ({ format: () => `${record.province} ${record.postalCode}`, zoneCode: () => record.postalCode.slice(0, 2), provinceDistrict: () => `${record.province}/${record.district}`, province: () => record.province, });
// short/shipment.mjs — shipment asks its neighbor and hands back the result under its own name import { address } from "./address.mjs"; export const shipment = (record) => { const a = address(record.recipient.address); return { code: record.code, labelLine: () => `${record.recipient.name} / ${a.format()}`, zoneCode: () => a.zoneCode(), provinceDistrict: () => a.provinceDistrict(), province: () => a.province(), tariffName: () => record.tariff.name, tierFee: () => record.tariff.tier.fee, }; };
// short/label.mjs — one step export const label = (shipment) => shipment.labelLine();
// short/fee.mjs — one step const ZONE = { "34": 100, "06": 115, "65": 140 }; export const fee = (shipment) => Math.round((shipment.tierFee() * (ZONE[shipment.zoneCode()] ?? 160)) / 100);
// short/report.mjs — one step export const report = (shipment) => `${shipment.code} ${shipment.provinceDistrict()} ${shipment.tariffName()}`;
// short/notification.mjs — one step export const notification = (shipment) => `${shipment.code} departed for ${shipment.province()}`;
// short/branch.mjs — one step export const branch = (shipment) => `${shipment.provinceDistrict().replace("/", "-")} branch`;
// short/setup.mjs — the same five outputs import { RECORD } from "./record.mjs"; import { shipment } from "./shipment.mjs"; import { label } from "./label.mjs"; import { fee } from "./fee.mjs"; import { report } from "./report.mjs"; import { notification } from "./notification.mjs"; import { branch } from "./branch.mjs"; const s = shipment(RECORD); for (const f of [label, fee, report, notification, branch]) console.log(f(s));
node short/setup.mjs
node chain-scan.mjs short
A. Yilmaz / Ankara 06500 7360 GN-000001 Ankara/Cankaya weight GN-000001 departed for Ankara Ankara-Cankaya branch address.mjs chains=6 deepest=2 findings=1 record.postalCode.slice branch.mjs chains=1 deepest=1 findings=0 fee.mjs chains=2 deepest=1 findings=0 label.mjs chains=1 deepest=1 findings=0 notification.mjs chains=2 deepest=1 findings=0 record.mjs chains=0 deepest=0 findings=0 report.mjs chains=3 deepest=1 findings=0 shipment.mjs chains=10 deepest=3 findings=4 record.recipient.address record.recipient.name record.tariff.name record.tariff.tier.fee short total findings=5 deepest chain=3
The same five outputs, but five findings instead of eleven. More importantly, look at how the findings are distributed: all five client modules dropped to zero findings, and the remaining findings gathered in the two modules that resolve the structure. The law of Demeter does not eliminate knowledge of the structure; it reduces the number of modules that know it.
The Files a Change Touches
The measure’s counterpart shows up in a structural change. The address record is being
reorganized: the province and district fields move into a nested record named
location. postalCode stays where it is. The in-place edit is given a backup extension;
GNU and BSD sed behave the same way with this form.
cp -r chained chained-new cp -r short short-new NEW='{ location: { province: "Ankara", district: "Cankaya" }, postalCode: "06500" }' OLD='{ province: "Ankara", district: "Cankaya", postalCode: "06500" }' sed -i.y "s#$OLD#$NEW#" chained-new/record.mjs short-new/record.mjs sed -i.y -e 's/\.address\.province/.address.location.province/g' -e 's/\.address\.district/.address.location.district/g' chained-new/*.mjs sed -i.y -e 's/record\.province/record.location.province/g' -e 's/record\.district/record.location.district/g' short-new/address.mjs rm -f chained-new/*.y short-new/*.y node chained-new/setup.mjs node short-new/setup.mjs for k in chained short; do echo "$k: edited files = $(diff -rq $k $k-new | grep -c '^Files') ->" \ "$(diff -rq $k $k-new | sed 's#.*/\([a-z]*\.mjs\) .*#\1#' | tr '\n' ' ')" done
A. Yilmaz / Ankara 06500 7360 GN-000001 Ankara/Cankaya weight GN-000001 departed for Ankara Ankara-Cankaya branch A. Yilmaz / Ankara 06500 7360 GN-000001 Ankara/Cankaya weight GN-000001 departed for Ankara Ankara-Cankaya branch chained: edited files = 5 -> branch.mjs label.mjs notification.mjs record.mjs report.mjs short: edited files = 2 -> address.mjs record.mjs
Both versions keep producing the same five lines. The difference is in cost: the same
structural change required editing five files in the chained design and two files in the
short design. In the chained version, the fee module was the only one left untouched, because
it used only the postalCode field — which module gets affected, in other words, depended on
which field name that module reached into.
Misapplying the Law
The wrong way to shorten the chain is to write a delegating method for every intermediate
field. An interface that grows as shipment.recipientName(), shipment.recipientProvince(),
shipment.recipientDistrict(), shipment.recipientPostalCode() shortens the chain, but it
copies the same information into method names instead; the interface grows as fields are
added, and the interface bloat measured in the previous lesson comes back. In the short
version above, the address object does not return raw fields — it answers the question
being asked: format, zoneCode, provinceDistrict. These are the names of the clients’
questions, not the names of the fields.
The law also has an exception: in records that exist purely to carry data — a serialized configuration object, or a parsed body at a boundary point — the chain is unavoidable and its cost is low, because that record is already an external contract. The measure is not whether a chain exists, but how many modules separately know the same structure.
Summary
- The law of Demeter limits the objects a function can reach to its own parts, its parameters, and the objects it constructs; its measure is the step count of the access chain.
- In the design where five clients reach directly into the record, 11 findings and a chain as deep as 4 steps were counted.
- Once the intermediate objects were given behavior, the finding count dropped to 5, and all five client modules dropped to zero findings; the remaining findings gathered in the two modules that resolve the structure.
- When the address record was restructured, 5 files were edited in the chained design and 2 in the short design; both versions kept producing the same five lines.
- Shortening the chain with one delegating method per field does not fix the measure; the intermediate object should answer the clients’ questions, not their field names.
Next Step
In the short version, the address object still returns values: provinceDistrict() hands
back a string, and the caller makes the decision. Branch selection takes that string and
formats it; notification uses another part of it. When the same rule repeats across several
call sites, the question changes: hand the data out and leave the decision to the client, or
tell the object the decision itself? The next lesson counts how many separate places the same
rule repeats in, and compares it with a version that moves the decision into the data.
To keep your progress and take notes, Log in
My notes
Log in to take notes.