Lesson 01 / 19
Single Responsibility Principle
Counting a module's number of reasons to change with an actor table: comparing a single module that carries three different actors' decisions against a design with the decisions separated, and measuring whether an accounting-driven change leaks into the operations report by counting output lines.
Contents
The Programming Paradigms course closed by naming the fact that both paradigms described how individual units should be built, but neither measured when the relationship between units is healthy. Whether a design is good is understood not by looking inside a unit but by looking at the bond between units. This course measures that bond.
The smallest question belongs to the first measure: when is a module considered to be doing too much? The common answer is “if it does too much work,” and it is useless, because there is no threshold for the amount of work. The single responsibility principle moves the question out of line count and onto the party requesting the change: a module is expected to change for only one actor’s decision. The measure here is not the module’s size but the number of separate parties that can send it a request to change.
Three Actors, One Module
The example that runs through the course is a shipment fee and routing library. The fee calculation is the product of three separate decisions: the base fee from the weight tier, the zone factor derived from the address, and the volume discount applied to a contracted customer. These decisions belong to different units: operations sets the zone factors, contract management sets the discount threshold, and accounting sets the rounding of the invoice amount.
mkdir -p combined separated
// combined/fee.mjs — three actors' decisions in a single module export const TIERS = [[1, 3900], [5, 6400], [20, 11800]]; export const ZONE = { "34": 100, "06": 115, "65": 140 }; export const VOLUME_THRESHOLD = 3; export const round = (cents) => Math.round(cents / 100) * 100; export const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160; export const discountRate = (count) => (count >= VOLUME_THRESHOLD ? 10 : 0); export function tierFee(weight) { for (const [max, fee] of TIERS) if (weight <= max) return fee; return TIERS.at(-1)[1] + Math.ceil(weight - TIERS.at(-1)[0]) * 900; } export function fee(shipment, count) { const raw = (tierFee(shipment.weight) * zoneFactor(shipment.address)) / 100; return round((raw * (100 - discountRate(count))) / 100); } export function zoneReport(shipments) { const totals = new Map(); for (const s of shipments) { const z = s.address.slice(0, 2); totals.set(z, (totals.get(z) ?? 0) + fee(s, shipments.length)); } return [...totals].sort().map(([z, c]) => `zone ${z} total ${c} cents`); }
// combined/report.mjs — operations' zone report and accounting's invoice amount import { zoneReport, fee } from "./fee.mjs"; const SHIPMENTS = [ { weight: 0.8, address: "34100" }, { weight: 4.2, address: "06500" }, { weight: 12.0, address: "65200" }, { weight: 2.5, address: "34700" }, ]; for (const line of zoneReport(SHIPMENTS)) console.log(line); console.log("invoice total =", SHIPMENTS.reduce((t, s) => t + fee(s, SHIPMENTS.length), 0), "cents");
node combined/report.mjs
zone 06 total 6600 cents zone 34 total 9300 cents zone 65 total 14900 cents invoice total = 30800 cents
The module is thirty lines and works correctly. A measure that looks at line count sees no problem here.
Counting the Reason to Change
The measure is obtained by writing which symbols each actor would change into a table and counting which files those symbols are defined in. The script below scans the modules in a directory and reports, for each module, how many separate actors carry a decision in it and how many files each actor touches.
// reason-count.mjs — counts how many actors' decisions each module changes for import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; // Symbols each actor will want to change. Requirements are read from this table. const ACTORS = { operations: ["ZONE", "zoneFactor", "zoneReport"], accounting: ["round", "invoiceTotal"], contracts: ["VOLUME_THRESHOLD", "discountRate"], }; const isDefined = (text, symbol) => new RegExp(`\\b(?:const|function|let|class)\\s+${symbol}\\b`).test(text); const root = process.argv[2]; const files = readdirSync(root).filter((f) => f.endsWith(".mjs")).sort(); const actorFiles = new Map(Object.keys(ACTORS).map((a) => [a, []])); let highest = 0; for (const f of files) { const text = readFileSync(join(root, f), "utf8"); const found = Object.entries(ACTORS) .filter(([, symbols]) => symbols.some((s) => isDefined(text, s))) .map(([a]) => a); for (const a of found) actorFiles.get(a).push(f); highest = Math.max(highest, found.length); console.log(`${f.padEnd(18)} reasons to change = ${found.length} ${found.join(" ")}`); } for (const [a, fs] of actorFiles) { console.log(`${a.padEnd(10)} -> ${fs.length} file: ${fs.join(" ")}`); } console.log(`highest reasons to change = ${highest}`);
node reason-count.mjs combined
fee.mjs reasons to change = 3 operations accounting contracts report.mjs reasons to change = 0 operations -> 1 file: fee.mjs accounting -> 1 file: fee.mjs contracts -> 1 file: fee.mjs highest reasons to change = 3
All three actors touch a single file, and that file’s number of reasons to change is three. This is what violates the principle: not the file’s size, but the fact that three separate parties will edit the same text.
Separating the Decisions
In the separated design, each actor’s decision stays in its own module. The tariff core left in the middle carries no actor’s decision directly; it produces the exact value in cents and does not round.
// separated/zone.mjs — operations' decision: zone factors export const ZONE = { "34": 100, "06": 115, "65": 140 }; export const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;
// separated/contracts.mjs — contract management's decision: volume discount export const VOLUME_THRESHOLD = 3; export const discountRate = (count) => (count >= VOLUME_THRESHOLD ? 10 : 0);
// separated/tariff.mjs — tariff core: exact fee in cents, no rounding import { zoneFactor } from "./zone.mjs"; import { discountRate } from "./contracts.mjs"; export const TIERS = [[1, 3900], [5, 6400], [20, 11800]]; export function tierFee(weight) { for (const [max, fee] of TIERS) if (weight <= max) return fee; return TIERS.at(-1)[1] + Math.ceil(weight - TIERS.at(-1)[0]) * 900; } export function fee(shipment, count) { const raw = (tierFee(shipment.weight) * zoneFactor(shipment.address)) / 100; return (raw * (100 - discountRate(count))) / 100; }
// separated/invoice.mjs — accounting's decision: invoice amount rounds to the whole lira import { fee } from "./tariff.mjs"; export const round = (cents) => Math.round(cents / 100) * 100; export const invoiceTotal = (shipments) => shipments.reduce((t, s) => t + round(fee(s, shipments.length)), 0);
// separated/zone-report.mjs — operations' decision: the report totals the exact fee import { fee } from "./tariff.mjs"; export function zoneReport(shipments) { const totals = new Map(); for (const s of shipments) { const z = s.address.slice(0, 2); totals.set(z, (totals.get(z) ?? 0) + fee(s, shipments.length)); } return [...totals].sort().map(([z, c]) => `zone ${z} total ${c} cents`); }
// separated/report.mjs — same two outputs, with the separated design import { zoneReport } from "./zone-report.mjs"; import { invoiceTotal } from "./invoice.mjs"; const SHIPMENTS = [ { weight: 0.8, address: "34100" }, { weight: 4.2, address: "06500" }, { weight: 12.0, address: "65200" }, { weight: 2.5, address: "34700" }, ]; for (const line of zoneReport(SHIPMENTS)) console.log(line); console.log("invoice total =", invoiceTotal(SHIPMENTS), "cents");
node separated/report.mjs
node reason-count.mjs separated
zone 06 total 6624 cents zone 34 total 9270 cents zone 65 total 14868 cents invoice total = 30800 cents contracts.mjs reasons to change = 1 contracts invoice.mjs reasons to change = 1 accounting report.mjs reasons to change = 0 tariff.mjs reasons to change = 0 zone-report.mjs reasons to change = 1 operations zone.mjs reasons to change = 1 operations operations -> 2 file: zone-report.mjs zone.mjs accounting -> 1 file: invoice.mjs contracts -> 1 file: contracts.mjs highest reasons to change = 1
The invoice total is the same in both designs. The zone report changed: the combined version was totaling rounded amounts, the separated version totals exact cents. The second measure reads the table from the other direction: files per actor for operations rose to two. This rise in file count is the price of the principle; its payoff shows up in the next measure.
The Leaking Change
Accounting introduces a new rule: the invoice amount rounds up, not to the nearest lira.
The change is writing Math.ceil instead of Math.round. The script below applies the
change to both designs and counts how many lines of output changed. The in-place edit is
given a backup extension for portability; GNU and BSD sed behave the same this way.
node combined/report.mjs > combined-before.txt node separated/report.mjs > separated-before.txt echo "touched file: $(grep -rl 'Math.round' combined separated | sort | tr '\n' ' ')" sed -i.bak 's/Math.round/Math.ceil/' $(grep -rl 'Math.round' combined separated) node combined/report.mjs > combined-after.txt node separated/report.mjs > separated-after.txt echo "combined: changed output lines = $(diff combined-before.txt combined-after.txt | grep -c '^<')" echo "separated: changed output lines = $(diff separated-before.txt separated-after.txt | grep -c '^<')" diff combined-before.txt combined-after.txt || true
touched file: combined/fee.mjs separated/invoice.mjs combined: changed output lines = 3 separated: changed output lines = 1 1,2c1,2 < zone 06 total 6600 cents < zone 34 total 9300 cents --- > zone 06 total 6700 cents > zone 34 total 9400 cents 4c4 < invoice total = 30800 cents --- > invoice total = 31000 cents
A single file was touched in both designs; the difference is not in how many files were touched but in the result. What accounting wanted changed was a single line, the invoice total: in the separated design, only that line changed. In the combined design, both lines of the zone report were also caught, because the report was totaling the product of the rounding decision. Operations did not request this change and did not know about it.
This is the measurable promise of the principle: once the number of reasons to change drops to one, one actor’s decision cannot break the output of an actor who does not share that decision. The price was also measured — operations’ file count rose to two. The principle trades a surprise for a file count.
Where the Boundary of Responsibility Lies
Building the table around “function” instead of actor is a common mistake. The zone factor and the zone report are different jobs, but both are operations’ decision; they are not required to stand in separate files. Conversely, rounding and the zone factor could be computed on the same line and still belong to different actors; keeping them together produces a measurable surprise.
The practical way to find the boundary is to look at where past change requests came from. If the same file was touched at the request of two different units, the number of reasons to change is greater than one. Setting up a separation ahead of any request is a cost in the opposite direction: file count rises, and no measured surprise decreases in exchange.
Summary
- The single responsibility principle bounds not a module’s size but the number of separate actors that can send it a request to change.
- The number of reasons to change is measurable: scanning the actor-symbol table found 3 in the combined module and at most 1 in the separated design.
- The measure’s payoff was seen with a run: the same accounting change altered 3 output lines in the combined design and 1 output line in the separated design.
- The cost of the separation was also counted: the number of files operations touches rose from one to two. The principle trades a surprise for a file count.
- The boundary is drawn not by similarity of function but by who the change request came from.
Next Step
In the separated design, each actor changed its own file, but each actor still edited
an existing file. Adding a new zone to the zone factor table means editing the object
inside zone.mjs. When a request touches not the tier table but the tariff’s type —
for instance, a second tariff that charges by volume instead of weight — the number of
files to edit rises again. The next lesson measures the number of lines changed against
the number of lines added in both designs when a new tariff type is added, and shows
what a structure means whose expansion requires no change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.