Single Responsibility Principle
Why bundling unrelated jobs onto one class creates hidden coupling, and how splitting a class along its true axes of change keeps each piece independently safe to modify.
Learning objectives
- State the Single Responsibility Principle in terms of 'reasons to change,' not method count
- Spot an SRP violation using the one-sentence, no-'and' test
- Split a bloated class along its genuine, independent axes of change
- Avoid the trap of splitting a class too aggressively
◆ Story
Imagine a single support agent whose job description reads: handle billing disputes, triage product bugs, and run new-hire onboarding. The moment billing policy changes, this person needs retraining. The moment the bug-triage tool changes, same person, same retraining. The moment onboarding paperwork changes — again. Every unrelated change in the company routes through one overloaded person, and teaching them a new billing rule risks them forgetting an onboarding step they also own.
A class carrying too many responsibilities is that overloaded agent. The Single Responsibility Principle — the first and arguably most foundational of the five SOLID principles — says a class should have exactly one reason to change: one clear, focused job.
SOLID itself is a set of five design principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — that together describe what makes object-oriented code safe to change over time as requirements shift. None of them are enforced by a compiler; they're judgment calls an experienced engineer applies almost automatically, and this course covers each one the same way: a concrete problem, a naive attempt that breaks in a specific, nameable way, and then the fix.
The example for this topic is a billing system's Invoice class, which starts out doing four things at once — holding data, formatting itself, saving itself, and emailing itself — and the question worth sitting with before writing any code: should all four genuinely live on the same class just because they're all "things an invoice does"?
The obvious first instinct: this is all "invoice behavior," so it all goes on the Invoice class. A constructor holds the amount, one method formats and prints the invoice, one method pretends to save it to a database, and one method pretends to email it to the customer.
Run it and it works. generateInvoice() prints correctly, saveToDatabase() "saves," sendInvoiceEmail() "sends." Nothing here throws an exception or produces a wrong answer. So the natural question: if it runs correctly, is this actually a problem?
It is — but not one a test run will ever catch, because the problem isn't in what the code produces, it's in how the code is organized. Invoice has quietly taken on three separate jobs that have nothing to do with each other except that they all happen to involve an invoice object.
💻 Code example
class Invoice { private double amount; Invoice(double amount) { this.amount = amount; } void generateInvoice() { System.out.println("Invoice generated for amount: " + amount); } void saveToDatabase() { System.out.println("Saving invoice to database..."); // responsibility #2 — persistence } void sendInvoiceEmail() { System.out.println("Emailing invoice to customer..."); // responsibility #3 — notification } }
Stop and ask what Invoice is actually supposed to be responsible for. Conceptually, an invoice should represent billing data and know how to describe itself. Whether that data ends up in MySQL or Postgres, and whether a customer is notified by email or SMS, are entirely separate concerns that have been bolted on.
- Unrelated changes now share one blast radius. Switching database providers means editing
Invoice. Changing how emails are sent means editingInvoice. A developer fixing an email-formatting bug is one careless edit away from breakinggenerateInvoice(), which they never intended to touch. - Tests couple to things they shouldn't. A unit test for
generateInvoice()'s formatting now sits in the same class as persistence and email logic — any test suite exercising this class has to reason about all three concerns at once, even when it only cares about one. - Nothing here is reusable. If a
PurchaseOrderclass later needs the exact same "save to database" logic, it can't reuseInvoice.saveToDatabase()without dragging in unrelated invoice-formatting code too.
This is precisely what SRP means by "a class should have only one reason to change": right now, Invoice has three — a billing-format change, a database change, and an email-provider change — each completely independent of the other two, and each capable of forcing an edit to a class that had nothing to do with the actual change.
Before writing any new class, name the actual axes of change, the way you'd reason about it at a whiteboard: billing logic changes when the business rules around what an invoice contains change; persistence logic changes for reasons that have nothing to do with billing at all — a new database, a new ORM; notification logic changes for yet another unrelated reason — a new email provider, a switch to SMS.
Three independent reasons to change map to three classes — not because "three feels tidier," but because each one genuinely varies on its own schedule, and none of those reasons should ever force a change in the other two.
Invoice keeps exactly what it started with — holding billing data and describing itself. The other two responsibilities move out entirely, each into a class named after the one job it does: InvoiceRepository persists things, and it takes an Invoice without knowing anything about how that invoice formats itself; EmailService sends notifications, and it does the same. Notice what each class's name now tells you without opening the file — a tax-law change touches only Invoice, a database migration touches only InvoiceRepository, and neither can accidentally break the other because neither knows the other exists.
💻 Code example
class Invoice { private final double amount; Invoice(double amount) { this.amount = amount; } double getAmount() { return amount; } void generateInvoice() { // the ONLY job left — describing itself System.out.println("Invoice generated for amount: " + amount); } } class InvoiceRepository { void save(Invoice invoice) { // takes an Invoice, knows nothing about how it formats itself System.out.println("Saving invoice to database, amount: " + invoice.getAmount()); } } class EmailService { void sendInvoiceEmail(Invoice invoice) { // owns only the sending System.out.println("Emailing invoice, amount: " + invoice.getAmount()); } } // usage Invoice invoice = new Invoice(499.0); InvoiceRepository repository = new InvoiceRepository(); EmailService emailService = new EmailService(); invoice.generateInvoice(); repository.save(invoice); emailService.sendInvoiceEmail(invoice);
A genuinely useful test for spotting an SRP violation: try to describe what a class does in one sentence, without using the word "and." If you can't — "this class formats an invoice and saves it to a database and emails it" — that "and" is very often marking exactly where the class should be split. Applying this test to the original Invoice class immediately surfaces two "and"s, pointing at exactly the two classes that needed to be pulled out.
▲ Edge case — splitting too aggressively
Taking SRP to an extreme — splitting a class into ten tiny pieces because each individual method is technically "different behavior" — creates its own real problem: excessive indirection, where understanding one simple operation means jumping through five classes. "One reason to change" is about a genuine axis of change, not about method count. If generateInvoice() and a hypothetical formatInvoiceAsPdf() both only ever change for "billing/display reasons," they can reasonably stay on the same class.
▲ Edge case — who constructs the three classes?
Splitting Invoice into three classes raises a real question the usage example above sidesteps by constructing them directly: in a larger system, something needs to own the job of wiring Invoice, InvoiceRepository, and EmailService together — a service layer, or a dependency-injection framework, as covered in the Dependency Inversion Principle topic. SRP tells you how to split responsibilities; it doesn't by itself tell you who assembles the pieces back together.
It's also worth knowing SRP isn't only a "big class" problem — a tiny class mixing one line of billing logic with one line of email logic still has two unrelated reasons to change, even at ten lines total.
This exact split — a plain data class, a persistence class, and a notification class — is precisely the shape of a typical Spring Boot application's @Entity, @Repository, and @Service layers. Invoice maps onto an @Entity holding billing data; InvoiceRepository maps almost literally onto Spring Data's @Repository interfaces, which exist purely to persist and query entities without knowing anything about business rules; EmailService maps onto an @Service class dedicated to one job. SRP isn't an academic idea confined to interview questions — it's the reasoning underneath one of the most common real-world backend architectures in use today.
The same instinct shows up in the Java standard library. String holds character data and knows how to describe itself, but it has no save() or send() method bolted onto it — persistence and networking are left entirely to other classes, like FileWriter or Socket, that specialize in exactly one of those concerns. Logging frameworks follow the same shape: a Logger only logs, and it delegates where those logs end up — a file, the console, a remote server — to a separate appender configured independently, so changing the destination never requires touching logging call sites scattered across the codebase.
Q: What does "one reason to change" actually mean in practice?
A: A class should be tied to exactly one independent axis of change — one business rule, one storage mechanism, or one output format — not an arbitrary count of methods or fields.
Q: What's a quick, practical test for spotting an SRP violation?
A: Try to describe the class's job in one sentence without using "and" — if you can't, that "and" usually marks where it should be split.
Q: Why does putting saveToDatabase() on Invoice cause a real testing problem, not just a style problem?
A: A unit test for Invoice's formatting logic now sits in the same class as persistence logic — the test suite for one concern can't be isolated from the other, and a change to persistence risks breaking tests that were only ever about formatting.
Q: Is it possible to violate SRP without writing a huge class?
A: Yes — SRP is about independent reasons to change, not size. A tiny class mixing one line of billing logic with one line of email logic still has two unrelated reasons to change.
Q: What's the risk of over-applying SRP?
A: Splitting along things that aren't genuinely independent axes of change creates excessive indirection — understanding one simple operation ends up requiring jumping through several tiny classes for no real benefit.
Want a visual for this concept?
Generate a diagram tailored to “Single Responsibility Principle” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →