Single Responsibility Principle
Intermediate · 10 min read · ▶ live playground · ✦ checkpoint
An invoice total is correct until the company changes its email layout. The developer editing that layout accidentally touches the same class that calculates totals and saves records, so a presentation request puts billing data at risk.
The Single Responsibility Principle (SRP) says a class should have one reason to change. A responsibility is not one method; it is one coherent area of knowledge that changes for the same kind of reason.
For an invoice, pricing rules, output formatting, and storage policy come from different decisions. When one class owns all three, unrelated changes collide.
Before: one class answers to three concerns
A God class is a class that gathers too many unrelated decisions and becomes a common destination for every change. The class below looks convenient because everything involving an invoice sits in one box. That convenience vanishes when finance, product, and infrastructure requests all edit the same file.
class diagram
Before: an invoice that does everything
- 1Invoice calculates money, turns itself into text, and decides how records are stored.
- 2Finance, product, and infrastructure requests all lead back to this one class.
- 3One class, three reasons to change: an edit in one concern can disturb the other two.
Count the methods and you might say the class is still small. Count the sources of change and the smell becomes clearer: a pricing rule changes total(), an email redesign changes format(), and a database migration changes save().
type InvoiceLine = {
description: string;
unitPrice: number;
quantity: number;
};
class Invoice {
private static records = new Map<string, string>();
private id: string;
private lines: InvoiceLine[];
public constructor(id: string, lines: InvoiceLine[]) {
this.id = id;
this.lines = lines;
}
// smell: pricing, presentation, and storage policies all change this class.
public total(): number {
return this.lines.reduce(
(sum, line) => sum + line.unitPrice * line.quantity,
0,
);
}
public format(): string {
return `Invoice ${this.id}: $${this.total().toFixed(2)}`;
}
public save(): void {
Invoice.records.set(this.id, this.format());
}
}The problem is not that these methods call one another. It is that Invoice must understand three policies that can move independently. A formatting change may alter the stored text because save() reuses format(). The class has made two concerns depend on each other by accident.
After: give each decision a home
The refactor keeps invoice mathematics with the invoice and moves the other policies into focused collaborators. Each class now has a smaller reason to change, and the relationships state what information each collaborator needs.
class diagram
After: focused invoice collaborators
- 1Invoice keeps the data and calculation rules that make an invoice valid.
- 2InvoiceFormatter borrows an invoice and owns only its text representation.
- 3InvoiceRepository owns storage; pricing and presentation no longer change with it.
- 4Each concern now changes in one focused place without pulling the others into the edit.
The dashed open arrow means the formatter uses an invoice temporarily. The plain repository line records a lasting reference to saved invoices. Neither collaborator becomes a parent of Invoice, and the invoice does not need to know who formats or stores it.
type InvoiceLine = {
description: string;
unitPrice: number;
quantity: number;
};
class Invoice {
private id: string;
private lines: InvoiceLine[];
public constructor(id: string, lines: InvoiceLine[]) {
this.id = id;
this.lines = lines;
}
public number(): string {
return this.id;
}
public total(): number {
return this.lines.reduce(
(sum, line) => sum + line.unitPrice * line.quantity,
0,
);
}
}
class InvoiceFormatter {
public format(invoice: Invoice): string {
return `Invoice ${invoice.number()}: $${invoice.total().toFixed(2)}`;
}
}
class InvoiceRepository {
private records = new Map<string, Invoice>();
public save(invoice: Invoice): void {
this.records.set(invoice.number(), invoice);
}
public find(id: string): Invoice | undefined {
return this.records.get(id);
}
}
function issueInvoice(
invoice: Invoice,
formatter: InvoiceFormatter,
repository: InvoiceRepository,
): string {
repository.save(invoice);
return formatter.format(invoice);
}
const invoice = new Invoice("INV-104", [
{ description: "Workshop seat", unitPrice: 80, quantity: 2 },
]);
const message = issueInvoice(
invoice,
new InvoiceFormatter(),
new InvoiceRepository(),
);
console.log(message);Invoice INV-104: $160.00
Invoice still has two methods. They belong together because the invoice number and total describe the same billing concept. InvoiceFormatter can change from plain text to a richer layout without touching the calculation. InvoiceRepository can move from memory to a database without changing either of them.
The small issueInvoice() function coordinates a workflow: save this invoice, then produce its message. Coordination is a responsibility too. It does not need to absorb the calculation, formatting, or storage decisions it connects.
When not to over-apply SRP
Cohesion is how strongly the data and behavior inside a class belong together. A cohesive class may contain several fields and methods because they work together to protect one concept. Splitting Invoice.number() and Invoice.total() into separate one-method classes would scatter the invoice without separating a real source of change.
An anemic class is a class with little meaningful behavior while outside code makes all of its decisions. If every calculation becomes a tiny service and Invoice turns into public fields, the design loses the encapsulation you built earlier. SRP is not an instruction to move all behavior away from the object that owns the rule.
Split when responsibilities change for different reasons, make the class difficult to explain, or force unrelated tests to move together. Keep behavior together when it protects the same invariant and changes as one unit. The goal is a focused boundary, not the highest possible class count.
Checkpoint
Answer all three to mark this lesson complete
You can now give each change pressure a focused home. Next, the Open/Closed Principle asks whether adding a new kind of behavior can happen in a new class instead of reopening a stable one.