---
title: 'Single Responsibility Principle'
source: 'https://academia.sh/en/courses/design-principles/single-responsibility-principle'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:17+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```sh
mkdir -p combined separated
```

```js
// 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`);
}
```

```js
// 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");
```

```sh
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.

```js
// 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}`);
```

```sh
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.

```js
// 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;
```

```js
// separated/contracts.mjs — contract management's decision: volume discount
export const VOLUME_THRESHOLD = 3;
export const discountRate = (count) => (count >= VOLUME_THRESHOLD ? 10 : 0);
```

```js
// 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;
}
```

```js
// 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);
```

```js
// 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`);
}
```

```js
// 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");
```

```sh
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.

```sh
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.
