The Intro
Welcome to the first stop of a five-part road trip through SOLID. Five letters, five principles, five posts, one tiny project per post that we break on purpose and then put back together.
If you have ever opened a file, scrolled, scrolled some more, and whispered "who hurt you?"... this one is for you.
Today's letter: S, the Single Responsibility Principle. Or, as I like to call it, "the one where your class has too many jobs".
The What
Let's get the textbook definition out of the way first:
A class should have only one reason to change.
Easy. Done. See you in the next post...
Okay, okay, I'll stop. That sentence is correct and also kind of useless when you are starting out, because nobody tells you what a "reason to change" even looks like.
So let's build something. We are going to write a small invoice feature for a coffee shop. It needs to:
- Add up the items in the order.
- Print a nice receipt as text.
- Save the invoice so the owner can find it later.
Three tasks. Pay attention to that number.
The How
Here is how most of us (me included) would write it on day one:
// src/invoice.ts
import { writeFile } from 'node:fs/promises'
interface Item {
name: string
price: number
quantity: number
}
export class Invoice {
constructor(
private readonly id: string,
private readonly items: Item[],
) {}
total(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
print(): string {
const lines = this.items.map(
(item) => `${item.quantity} x ${item.name} ... $${(item.price * item.quantity).toFixed(2)}`,
)
return [`Invoice #${this.id}`, ...lines, `TOTAL: $${this.total().toFixed(2)}`].join('\n')
}
async save(): Promise<void> {
await writeFile(`./invoices/${this.id}.txt`, this.print())
}
}
And the usual main:
// src/main.ts
import { Invoice } from './invoice.js'
const invoice = new Invoice('0001', [
{ name: 'Latte', price: 4.5, quantity: 2 },
{ name: 'Croissant', price: 3, quantity: 1 },
])
console.log(invoice.print())
await invoice.save()
Run it with npx tsx src/main.ts and you get a lovely receipt in the terminal and a file in ./invoices/0001.txt.
Works on my machine! 🙌
Now let's play a game called "what happens next week". Three tickets land on your desk:
- Ticket 1: "Taxes! We need 10% tax on the total." (money logic)
- Ticket 2: "Can the receipt be HTML so we can email it?" (formatting)
- Ticket 3: "Let's store invoices in Postgres instead of text files." (storage)
Three completely different people asked for these: the accountant, the designer, and the backend lead. And every single one of them is going to make you open... invoice.ts.
That's the smell. Invoice has three reasons to change because it has three jobs: doing math, drawing text, and talking to the disk.
And it gets worse:
- Want to test
total()? Your test file now imports something that touches the file system. Fun. - The HTML ticket is going to add a second
print()method, then aformatflag, then anif... - The Postgres ticket drags a database driver into a class whose only real purpose was "add some numbers".
Let's untangle it.
Step 1: name the jobs
Before writing any code, say the responsibilities out loud:
- Calculating what the customer owes.
- Rendering the invoice for a human to read.
- Persisting the invoice somewhere.
Three sentences, three classes. That's the whole trick, honestly.
Step 2: keep the data dumb
Invoice becomes a plain bag of data plus the math that belongs to it:
// src/invoice.ts
export interface Item {
name: string
price: number
quantity: number
}
export class Invoice {
constructor(
readonly id: string,
readonly items: Item[],
) {}
total(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
}
No fs import. No formatting. Just an invoice being an invoice.
Step 3: one class to draw it
// src/invoice-printer.ts
import type { Invoice } from './invoice.js'
export class InvoicePrinter {
printText(invoice: Invoice): string {
const lines = invoice.items.map(
(item) => `${item.quantity} x ${item.name} ... $${(item.price * item.quantity).toFixed(2)}`,
)
return [`Invoice #${invoice.id}`, ...lines, `TOTAL: $${invoice.total().toFixed(2)}`].join('\n')
}
}
When the HTML ticket arrives, you add a printHtml() method right next to printText(). Invoice never hears about it.
Step 4: one class to store it
// src/invoice-repository.ts
import { writeFile } from 'node:fs/promises'
import type { Invoice } from './invoice.js'
import { InvoicePrinter } from './invoice-printer.js'
export class InvoiceRepository {
private readonly printer = new InvoicePrinter()
async saveToFile(invoice: Invoice): Promise<void> {
await writeFile(`./invoices/${invoice.id}.txt`, this.printer.printText(invoice))
}
async saveToDatabase(invoice: Invoice): Promise<void> {
// Pretend this is your favorite database client.
// The point is: nobody outside this file needs to know how it's done.
console.log(`INSERT INTO invoices (id, total) VALUES ('${invoice.id}', ${invoice.total()})`)
}
}
The Postgres ticket? Open invoice-repository.ts, fill in saveToDatabase(). Nothing else in the project cares.
Step 5: wire it up
// src/main.ts
import { Invoice } from './invoice.js'
import { InvoicePrinter } from './invoice-printer.js'
import { InvoiceRepository } from './invoice-repository.js'
const invoice = new Invoice('0001', [
{ name: 'Latte', price: 4.5, quantity: 2 },
{ name: 'Croissant', price: 3, quantity: 1 },
])
console.log(new InvoicePrinter().printText(invoice))
await new InvoiceRepository().saveToFile(invoice)
Run it again. Same receipt, same file, zero drama.
(Yes, it's TypeScript again. Swap the syntax for your favorite language and nothing about the idea changes.)
The Why
Let's replay those three tickets with the new layout:
-
Taxes → open
invoice.ts, touchtotal(). The printer and the repository don't care. -
HTML receipt → open
invoice-printer.ts, addprintHtml(). The invoice math and the storage don't care. -
Postgres → open
invoice-repository.ts, touchsaveToDatabase(). The math and the receipt don't care.
Each file has exactly one person who would ever ask you to edit it. That is what "one reason to change" means in practice: one stakeholder, one job, one file.
Some bonus points you get for free:
-
InvoiceRepositoryhas two methods, but still one job: storing invoices. "Where" is a detail, "storing" is the responsibility. (In the last post of this series we'll see how to hide even that detail.) -
Invoiceis now trivial to unit test. No disk, no mocks, just numbers in and numbers out. - Code reviews get smaller because a tax change can't accidentally break the receipt layout.
- Git blame stops looking like a crime scene with four people editing the same 200-line class.
One warning before you go wild: SRP is not "every class has one method". A class can have ten methods and still have one job. The question is always "who would ask me to change this?". If the answer is "several unrelated people", split it. If the answer is "one", leave it alone.
The Goodbye
That's the S. Next time we grab the O (Open/Closed) and find out why that if/else chain for discounts is about to become your least favorite part of the codebase.
Until then, go find a class with too many jobs and give it some coworkers. Happy coding 🤝
Top comments (0)