Why does every change break something you never touched?
5 min read
The first SOLID principle is not about doing one thing. It is about something more useful and stranger: having a single owner.
Northbound Coffee is a made-up coffee shop. Its Order class has been growing for two years and today it looks like this:
class Order {
constructor(private readonly items: Item[]) {}
total() { /* adds up the items */ }
toEmailBody() { /* the confirmation email */ }
toAccountingLine() { /* the row in the finance report */ }
}Finance asks for something trivial: amounts in the accounting report should have no decimals. You change a 2 to a 0. How many tests break?
The surprise: the email breaks
Two do: the accounting report (expected) and the customer confirmation email (nobody touched it). The reason sits three methods down, in the private helper both of them use to format money.
If your reaction is "fine, I'll pull the formatting into two functions" — correct, but that's the fix, not the diagnosis. And without a diagnosis the same bug walks back in next month through a different door. What just happened has a name: Order has two owners.
The intuition: one notebook per department
Picture Northbound Coffee with a single physical notebook in the back office. Finance writes its month-end numbers in it, Marketing keeps its email templates there, Operations logs stock. Everyone is tidy, everyone has their own section.
One day Finance decides amounts are rounded and crosses out the convention written on page one. Marketing, which leaned on that same page, ends up with broken emails without having touched a thing.
The problem was never the handwriting. It's that three departments share one notebook. The fix isn't writing more carefully — it's giving each department its own.
That's the Single Responsibility Principle (SRP): a module should have one reason to change. And "reason to change" doesn't mean "does one thing" — it means one person or department who can ask you to change that file.
Play with the formatter below: change the 2 in toFixed(2) to a 0 and watch what it takes down with it.
The example, step by step
Back to the real code. The naive version keeps everything in the class:
type Item = { name: string; unitPrice: number; qty: number };
class Order {
constructor(private readonly items: Item[]) {}
total() {
return this.items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
}
private money(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}
}Nothing smells yet: money is a private detail and total is what anyone expects an order to know. The trouble starts when two different consumers hang off that private:
toEmailBody() {
return `Thanks for your order. Total: ${this.money(this.total())}`;
}
toAccountingLine() {
return `${todayIso()};SALE;${this.money(this.total())}`;
}toEmailBody answers to Marketing. toAccountingLine answers to Finance. Two departments writing on the same page, and the compiler has no way to warn you: the code is valid, typed, and passes the linter.
Now the change Finance asked for — and what it drags along:
private money(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
return `$${(cents / 100).toFixed(0)}`;
}The fix isn't duplicating the formatter inside the class. It's moving each use into its own notebook, and leaving Order with the only thing nobody outside argues about — what the order is worth:
export class Order {
constructor(private readonly items: Item[]) {}
total() {
return this.items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
}
}// Marketing's notebook: changes when the copy changes.
export function renderConfirmationEmail(order: Order) {
const amount = `$${(order.total() / 100).toFixed(2)}`;
return `Thanks for your order. Total: ${amount}`;
}// Finance's notebook: changes when the accounting rule changes.
export function toAccountingLine(order: Order, date: string) {
const amount = `$${(order.total() / 100).toFixed(0)}`;
return `${date};SALE;${amount}`;
}Yes, toFixed shows up twice. That isn't duplication: they're two different rules that happen to share syntax today. Merging them is exactly the mistake we just walked out of.
Your turn
A teammate sees the result and proposes this, "so we don't repeat the formatting":
export function formatMoney(cents: number, decimals = 2) {
return `$${(cents / 100).toFixed(decimals)}`;
}Both notebooks import it and each passes its own decimals. Does this respect SRP, or is it the same notebook under a new name?
See the test
It depends on who can ask formatMoney to change.
As long as it just formats a number with a separator, the only person touching it is whoever maintains that formatting, and it's a legitimate module: parameter in, no business rule.
It becomes the shared notebook the moment an if moves in. A
formatMoney(cents, { locale, currency, roundingForLedger }) already takes
requests from Marketing, Finance, and the i18n team. The signal isn't the
number of importers — it's the number of departments that can open a ticket
against that file.
Going deeper
The definition changed, and almost nobody noticed. In Agile Software Development (2002), Robert C. Martin wrote "a class should have only one reason to change", and for fifteen years the industry read that as "a class should do one thing". In Clean Architecture (2017) he reworded it to shut that reading down: "a module should be responsible to one, and only one, actor". The second version is actionable — it forces you to name a person — and the first isn't: "one thing" survives any granularity you feel like defending.
The opposite failure has a name too. Apply SRP as "one function per file" and you land in shotgun surgery (Martin Fowler's term in Refactoring): one business change forces you through fourteen files because the responsibility got pulverized. SRP doesn't say "more files". It says "boundaries go where the actors' boundaries are": what changes together stays together, what changes for different reasons gets split.
You find it with git, not with intuition. History knows who asks each file for changes:
git log --format='%an' -- src/order.ts | sort | uniq -c | sort -rnA file touched by people from three teams, in commits that share no motive, is a shared notebook no matter how coherent the class name sounds. It's the cheapest signal you have and it costs zero lines of reading.
And it's an org-chart principle, not a code one. Conway's law: architecture ends up mirroring the organization's communication structure. SRP is that law applied on purpose rather than suffered — if Finance and Marketing are separate teams, so are their modules. The uncomfortable corollary is that the same class can violate SRP at one company and respect it at another, with identical code, because what changed is who gets to ask for the change.
Takeaways
- The unit of SRP is the actor, not the function. Before splitting, name the person who can request that change; if you can't name them, you don't have a responsibility, you have a label.
- Two identical fragments aren't duplication if they change for different reasons. Merging them creates the coupling SRP exists to avoid.
- The symptom arrives before the diagnosis: the day a change breaks a test nobody touched, you already know there are two owners in one file.
Open the biggest file in your project and run the git log above. If three names from three different teams come out, you've got your first candidate — and you know where the seam goes. Next in the series: the second principle, and why adding a third if to a switch means the design is pushing back.