Single responsibility principle
Invoice.ts was edited 31 times last quarter. Finance changed how GST is rounded. Design
changed the invoice layout twice. The platform team moved the table from MySQL to
Postgres. Three teams, one file, a merge conflict most weeks. Then a layout change
shipped with a tax bug in it, because the person fixing the header also “tidied” a line
in the total that looked unused.
The file was not too long. It was answering to too many people.
An Invoice class calculates its total with tax, renders itself as HTML, and saves itself to the database. Which different people or teams might ask for a change to this one class? List them.
The idea
The usual wording is “a class should have only one reason to change”. That is easy to recite and hard to use, because what counts as a reason? Robert C. Martin’s later wording is the one you can act on: a class should be responsible to one actor. An actor is a person or team who asks for changes.
In a restaurant the chef cooks, the cashier bills and the rider delivers. When the GST rate changes, the cashier’s work changes and the kitchen does not hear about it. If the chef also kept the books, every tax notice would interrupt dinner, and a mistake in the ledger could burn the dal.
Before: three bosses
type Item = { name: string; price: number; quantity: number }
class Invoice {
constructor(private readonly items: Item[]) {}
// Finance decides this.
total(): number {
const subtotal = this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
)
return subtotal + subtotal * 0.05
}
// Design decides this.
toHtml(): string {
return `<h1>Invoice</h1><p>Total: ${this.total()}</p>`
}
// The platform team decides this.
save(): string {
return `INSERT INTO invoices (total) VALUES (${this.total()})`
}
}
const items: Item[] = [{ name: 'Masala dosa', price: 120, quantity: 2 }]
const invoice = new Invoice(items)
console.log(invoice.total()) // 252
console.log(invoice.save()) // INSERT INTO invoices (total) VALUES (252)It works. The problem is not visible in the code, it is visible in the git log. Every change by any of the three teams opens this file, re-tests this file, and can break the other two teams’ parts of it.
After: one boss each
type Item = { name: string; price: number; quantity: number }
// Finance.
class Invoice {
constructor(private readonly items: readonly Item[]) {}
total(): number {
const subtotal = this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
)
return subtotal + subtotal * 0.05
}
}
// Design.
function renderInvoiceHtml(invoice: Invoice): string {
return `<h1>Invoice</h1><p>Total: ${invoice.total()}</p>`
}
// Platform.
class InvoiceRepository {
save(invoice: Invoice): string {
return `INSERT INTO invoices (total) VALUES (${invoice.total()})`
}
}
const items: Item[] = [{ name: 'Masala dosa', price: 120, quantity: 2 }]
const invoice = new Invoice(items)
const repository = new InvoiceRepository()
console.log(renderInvoiceHtml(invoice)) // <h1>Invoice</h1><p>Total: 252</p>
console.log(repository.save(invoice))
// > INSERT INTO invoices (total) VALUES (252)Same behaviour, three places. A layout change now opens one function. The move to Postgres opens one class. Nobody tidying the header can reach the tax line.
Two details are worth a look. The renderer is a plain function, because it has no state
to protect. The repository is a class, because the real one would hold a database
connection. And readonly Item[] means Invoice promises not to change the array it was
given.
How to spot it
Three tests, in the order I use them.
Say what the class does without using “and”. “It calculates the total and renders HTML and saves to the database” is three jobs. “It knows what the customer owes” is one.
List who would ask for a change. If you write down two job titles, you have found the seam. This is the test that works when the first one is ambiguous.
Look at the imports. A class that imports a tax library, a template engine and a database driver is three things glued together. The import list does not lie.
Getting it wrong in the other direction
InvoiceSubtotalCalculator, InvoiceTaxCalculator, InvoiceTotalCalculator. Each has
one method, and to understand how a total is computed you now read three files. Single
responsibility does not mean a class does one thing. It means the things it does change
for the same reason. Subtotal, tax and total all change when finance says so. They
belong together.
OrderManager, UserHelper, PaymentUtils. A vague name is a sign the class has no
single job to be named after, so it becomes the place where anything order related gets
put. If you cannot name it after what it does, it probably does several things.
This is the follow up, and “one reason to change” on its own will not survive it. Say: “I ask who would request a change. If two different stakeholders could ask for changes to the same class for unrelated reasons, I split along that line. And I keep together what changes together.” Then give the invoice example in two sentences. An answer with a named actor in it sounds like someone who has done it.
type Item = { name: string; price: number; quantity: number }readonly Item[]function renderInvoiceHtml(invoice: Invoice): string(sum, item) => sum + item.price * item.quantityexport class InvoiceCheckpoint
1. What is the most useful meaning of "one reason to change"?
2. A class computes subtotal, tax and grand total for an invoice. A colleague wants to split it into three classes for single responsibility. Should you?
3. UserService validates signup forms, hashes passwords, sends the welcome email and writes audit logs. What is the first sign in day to day work that this is a problem?
Single responsibility says a class should have one reason to change, and the useful way to read that is one actor: one person or team who asks for changes to it. An invoice class that calculates tax, renders HTML and saves to the database answers to finance, design and the platform team, so their changes collide in one file. I split it along those lines: the invoice keeps the money rules, rendering becomes its own function, and persistence becomes a repository. I do not split further than that, because things that change together belong together. The way I find a violation is to describe the class without the word and, or to look for a file that turns up in every pull request.
