Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close