SOLID Principles — How SRP Violation Broke Order Processing
NullPointerExceptions from an email template cascaded through order pipeline — all from a single SRP violation.
20+ years shipping production systems from the metal up. Notes here come from systems that actually shipped.
- SOLID is five design principles: SRP, OCP, LSP, ISP, DIP
- SRP: One class, one reason to change — split by owning team
- OCP + LSP: Open for extension via polymorphism, subtypes must honour parent contract
- ISP: No class should implement methods it doesn't use — split fat interfaces
- DIP: Depend on abstractions, not concretions — enables testability
- Production truth: Violations cause 3x longer feature cycles and random breakages on deploy
Imagine a Swiss Army knife that someone keeps adding tools to — a corkscrew, then a saw, then a blowtorch — until it's so heavy and tangled you can't open your scissors without triggering the spoon. SOLID principles are the rules that stop your code from becoming that knife. Each class should do one job cleanly, be open to growth without breaking existing behaviour, and slot in and out like a clean, labelled drawer rather than duct-taped junk. Follow these five rules and your codebase stays as easy to change on day 500 as it was on day one.
Every developer has inherited a codebase that made them want to quit. One change breaks three unrelated features. A simple bug fix requires touching seven files. A new developer joins the team and needs two weeks just to understand what a single class does. This isn't bad luck — it's the predictable result of ignoring design principles that have been battle-tested for decades. SOLID is a set of five principles coined by Robert C. Martin ('Uncle Bob') that act as guardrails against this exact kind of entropy.
The problem SOLID solves is called 'software rot' — the slow decay of a codebase under the weight of new requirements, quick fixes, and growing complexity. When classes have too many responsibilities, when changing one module forces changes in ten others, when you can't reuse a component without dragging its dependencies along for the ride, the codebase becomes expensive and risky to change. SOLID gives you a vocabulary and a concrete checklist to fight back.
By the end of this article you'll understand not just what each letter in SOLID stands for, but why each principle exists, what pain it prevents, and how to apply it in real Java code. You'll recognise violations in code reviews, explain trade-offs in interviews, and write classes that your future self will actually thank you for.
Don't skip the 'why' — the principles without context are just rules to memorise. The real power comes when you see a violation and say, 'That's going to hurt in six months.' That's the shift from junior to senior.
What SOLID Principles Actually Enforce
SOLID is a set of five design principles for object-oriented software: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. They enforce a specific contract between a class and its dependencies: each class should have exactly one reason to change, should be open for extension but closed for modification, and should depend on abstractions, not concretions. Violating any one of these creates coupling that spreads across the codebase like a crack in a windshield.
In practice, SOLID works by forcing you to decompose behavior into small, focused interfaces and classes. A class that follows SRP has one job — it either handles persistence, or business logic, or formatting, but never two. When you need to change how data is stored, you swap the repository implementation without touching the order processor. This keeps the cost of change linear with the scope of the change, not exponential.
Use SOLID from day one on any system that will outlive a single deployment. In production, the cost of a single SRP violation in a core path like order processing is measured in hours of debugging and weeks of regression testing. Teams that skip SOLID end up with god classes that break every time a new payment method or shipping rule is added. The principles are not academic — they are the difference between a system you can refactor and one you must rewrite.
OrderService.pay() method. Six months later, a new compliance rule required email to be sent only after payment settled, not on initiation. The change required modifying the payment flow, which broke three other payment methods.S — Single Responsibility Principle: One Class, One Reason to Change
The Single Responsibility Principle (SRP) states that a class should have only one reason to change. Not one method, not one line — one reason. 'Reason to change' is the key phrase. It means one actor — one part of the business — should own that class.
Think of a restaurant kitchen. The chef cooks. The waiter delivers food. The accountant manages invoices. If the chef also handles invoicing, then a tax law change forces you to retrain your chef. That's SRP violated in real life.
In code, the violation usually looks like a class called something vague: UserManager, OrderService, DataProcessor. These names are red flags. They hint that the class is doing formatting, persistence, validation, and business logic all at once. When your UI team wants to change the email format and your database team wants to change the storage schema, they're both editing the same class — and stepping on each other.
SRP doesn't mean each class has one method. A class can have many methods — as long as they all serve the same single responsibility. A UserEmailFormatter can have formatWelcomeEmail(), formatPasswordResetEmail(), and formatInvoiceEmail() — that's fine. They all belong to email formatting. The test: if two different people in your organisation could ask you to change this class for different reasons, it has more than one responsibility.
Senior Engineer Deep Dive: SRP is also about change risk. When a class has multiple responsibilities, a change to one responsibility can silently break another. In production, this manifests as mysterious test failures after unrelated commits. The 2-line email format change that breaks order processing is the classic. Always split by ownership boundary — not by method count.
Real-world failure: At a fintech company, the 'TransactionService' handled validation, fraud checks, email notifications, and audit logging. A change to add a new fraud rule accidentally disabled email notifications — customers didn't receive receipts. It took two days to trace the issue. After splitting, each change was isolated. Always split by ownership boundary — not by method count.
O & L — Open/Closed and Liskov Substitution: Design for Extension, Not Mutation
These two principles work so closely together that understanding one without the other leaves a gap. Let's tackle them as a pair.
Open/Closed Principle (OCP) says a class should be open for extension but closed for modification. Meaning: when a new requirement arrives, you should be able to add new code — not rewrite existing, tested code. Think of a plugin system in a text editor. You add a new language plugin without touching the editor's core source.
The classic OCP violation is a giant if/else or switch statement that grows every time a new type is added. Every addition is a risk — you're editing tested, deployed code.
The fix is almost always polymorphism: define an abstraction (interface or abstract class) and let new behaviour come in as new implementations.
Liskov Substitution Principle (LSP) tightens that: if B extends A, you must be able to use B anywhere A is expected — without the calling code knowing or caring. The child class must honour the contract of the parent. The most famous LSP violation is Square extends Rectangle. Mathematically a square is a rectangle, but in code, Square.setWidth() must also change the height — which breaks any code that sets width and height independently and then checks area.
LSP failure shows up as instanceof checks, unexpected exceptions from child classes, or broken behaviour when you swap implementations. If you need an instanceof check to handle a subclass differently, LSP is violated.
Senior Engineer Deep Dive: OCP is often misinterpreted as 'put an interface on everything.' That's premature abstraction. The rule: apply OCP only when you have two or more variants of a behaviour. A single implementation doesn't need an interface — wait until the second one appears. LSP violations in production are dangerous because they're silent. A subclass that fails to honour the contract will crash the calling code with no obvious link — the bug appears far from the violation.
Real-world failure: A team had a NotificationSender base class with a send() method that threw UnsupportedOperationException for email when the subclass only supported SMS. Code that iterated over a list of NotificationSender objects crashed when it hit the SMS-only one. Every caller had to check instanceof to avoid the exception — classic LSP failure. The fix: split into EmailSender and SmsSender interfaces.
if (shape instanceof Square) inside code that's supposed to work with any Shape, you've broken LSP. The fix is usually to reconsider the inheritance hierarchy — maybe Square and Rectangle shouldn't share a mutable parent, or the parent's interface needs to be more restrictive.I & D — Interface Segregation and Dependency Inversion: Keep Contracts Lean and Dependencies Flexible
Interface Segregation Principle (ISP) says don't force a class to implement methods it doesn't need. Fat interfaces are a smell. If you have an Animal interface with , walk(), and swim(), then your fly()Dog class is forced to implement — which makes no sense. The fix is to split fly()Animal into Walkable, Swimmable, and Flyable. A Duck implements all three. A Dog implements the first two. Clean.
ISP violations usually show up as throw new in an interface implementation — that's a class screaming that it was forced to sign a contract it can't honour.UnsupportedOperationException()
Dependency Inversion Principle (DIP) is the most architecturally powerful principle. It has two parts: (1) high-level modules should not depend on low-level modules — both should depend on abstractions; and (2) abstractions should not depend on details — details should depend on abstractions.
In plain English: your business logic (OrderService) shouldn't have new MySQLOrderRepository() hardcoded in it. If it does, you can never test OrderService without a live database, and you can never swap MySQL for PostgreSQL without editing business logic. Instead, OrderService depends on an OrderRepository interface. The concrete database class implements that interface. This is also the foundation of Dependency Injection — you inject the implementation from outside.
DIP is what makes unit testing possible at scale. Without it, every test needs the real database, the real email server, the real payment gateway.
Senior Engineer Deep Dive: ISP is often confused with having many small interfaces. The real test: does removing one method from the interface break any existing consumer? If yes, that method belongs to a separate interface. DIP is about where the dependency arrow points. In a layered architecture, your domain layer should define the repository interface — not the infrastructure layer. The infrastructure module implements the interface defined by the domain. This keeps your business logic stable and portable.
Real-world failure: A team had a ReportExporter interface with methods exportToPDF, exportToCSV, exportToExcel. The ExcelExporter implementation threw UnsupportedOperationException for exportToPDF because the requirement changed — but the interface wasn't updated. Every time a new report type was added, all existing exporters had to add a stub. The fix: split into PDFExportable, CSVExportable, ExcelExportable.
When SOLID Backfires: Common Anti-Patterns and Over-Engineering
SOLID is a tool, not a religion. Applying it blindly creates its own problems. Let's look at three anti-patterns you'll see in production codebases that took SOLID too far.
Anti-pattern 1: Micro-classes for everything. SRP doesn't mean one method per class. Some teams split so aggressively that a single feature requires 15 tiny classes with no clear ownership. You end up with CreateOrderRequestValidator, CreateOrderRequestSanitizer, CreateOrderRequestLogger — each with one method. Debugging becomes a maze of files that do nothing but delegate. Fix: SRP is about one reason to change, not one operation per class. Group cohesive methods that serve the same business concern.
Anti-pattern 2: Preemptive abstraction. OCP says be open for extension, but that doesn't mean wrap every class in an interface from day one. You'll get UserServiceImpl implements UserService where UserService has exactly one implementation and probably always will. The indirection adds no value. Fix: apply OCP where you have genuine variation points — places where you know or strongly suspect multiple implementations. Don't abstract before the second concrete use case arrives.
Anti-pattern 3: Dependency injection mania. DIP is great, but using a DI framework as a crutch can hide DIP violations. You see developers annotate fields with @Autowired but still write logic that casts to a concrete class internally. If you call ((MySQLUserRepository) repository).executeNativeQuery() inside your service, you've bypassed DIP regardless of how the wiring happened. Fix: your service code should only use methods declared on the interface. If you need a framework-specific feature, question the abstraction.
Senior Engineer Deep Dive: The most expensive SOLID anti-pattern is premature extraction. It adds indirection without reducing risk. A single-interface-single-implementation pair is a net negative: you maintain two files, add cognitive load, and gain zero flexibility. The second implementation is where the value appears. Wait for it.
Real-world failure: A startup adopted 'strict SOLID' from day one. Each microservice had interfaces for every class, resulting in a codebase with 40% boilerplate interfaces. When they pivoted, they had to change dozens of interface contracts — every change cascaded. The overhead of maintaining the abstractions outweighed the benefits. They refactored to remove interfaces that only had one implementation. The lesson: premature SOLID is as harmful as no SOLID.
- Start with SRP when you see a class that changes for multiple reasons.
- Add OCP abstraction only when you have at least two concrete implementations.
- Use composition over inheritance — it naturally satisfies LSP and ISP.
- Let DIP guide your dependency injection, but don't inject everything — use defaults for stable dependencies.
- Refactor toward SOLID incrementally, not in one giant 'design sprint'.
SOLID in Modern Java: Sealed Classes, Records, and Pattern Matching
Java 17+ introduced language features that align naturally with SOLID — and some that change how you apply it. Let's break them down.
Sealed Classes and OCP/LSP. Sealed classes let you define a fixed set of subtypes. This actually strengthens OCP: the sealed class defines the contract (closed for modification), and the permitted subtypes are the extension points. But it also prevents arbitrary inheritance — which can be too restrictive. Use sealed classes when the set of subtypes is known and controlled (e.g., a Command type). Case in point: PaymentMethod could be a sealed interface with CreditCardPayment, PayPalPayment, etc. — you control the subtypes, so callers can safely exhaustively match (pattern matching in Java 17+). This also helps LSP because the compiler enforces the exact set of implementations.
Records for DIP and SRP. Records are perfect for DIP abstractions — they carry data but no behaviour. A PaymentRequest record can represent the input to a PaymentGateway interface. No hidden state, no side effects. They also help SRP: a record clearly models one piece of data, like EmailMessage. You won't accidentally add persistence logic to a record.
Pattern Matching for OCP. The new switch expressions with pattern matching let you handle multiple implementations without instanceof. This doesn't replace polymorphism — it complements it. For cases where you need to branch on type but want to keep the branching code closed for modification (e.g., serialization logic), pattern matching with sealed classes provides a type-safe, exhaustive approach.
Risk: Overuse of Records with DIP. Records are immutable. If your DIP abstraction requires mutable state (which it shouldn't), records force you to reconsider. That's a good thing. But don't make every DTO a record if you need complex validation — keep validation in separate classes (SRP).
Senior Engineer Deep Dive: Modern Java features reduce boilerplate but don't replace thinking. A sealed interface doesn't automatically satisfy OCP — you still need the abstraction to be stable. Records don't make SRP violations disappear — they just make data carriers cleaner. Use them to implement SOLID more elegantly, not as a substitute for understanding the principles.
Real-world failure: A team used sealed classes for a Command pattern but later needed to add a new command from an external plugin. Because the sealed class only allowed known subtypes, they had to refactor to an open interface. Sealed classes are great for finite variants, but they're not a universal OCP solution. Know when to use them.
SOLID in Practice: Trade-offs and Real-World Decision Making
Senior engineers don't apply SOLID mechanically — they weigh trade-offs. Here's how to think about SOLID in real projects.
Trade-off: Abstraction cost vs. flexibility gain. Every interface is a layer of indirection. It adds files, increases cognitive load, and can make tracing code harder (you have to find the concrete implementation). The gain is that you can swap implementations without changing callers. The threshold: wait until you have at least two implementations or strong evidence a second will come. Premature abstraction is speculation.
Trade-off: SRP vs. cohesion. You can take SRP so far that a single business flow requires orchestrating ten classes. That reduces coupling but increases complexity. The sweet spot: group methods that use the same data and change for the same reason. If two methods always change together, they probably belong in the same class.
Trade-off: DIP and framework coupling. Frameworks like Spring handle dependency injection, but they introduce their own coupling. Your code depends on Spring annotations. That's not inherently bad — the trade-off is acceptable for most projects. But if you're building a library that others will embed, consider manual DI to reduce framework dependencies.
The 80/20 of SOLID. In most codebases, 80% of SOLID's value comes from applying SRP and DIP consistently. LSP and ISP matter most in class hierarchies and public APIs. OCP is critical in frameworks and plugin systems but less so in application logic. Spend your energy where it hurts most.
Senior Engineer Deep Dive: The ultimate test of SOLID is not whether your classes are perfectly decoupled — it's whether you can make a change to one part of the system without breaking another. That's the metric that matters. Chase that, and the principles will follow naturally.
Real-world failure: A team spent two months refactoring a legacy codebase to full SOLID compliance before any new feature work. The refactoring introduced bugs and delayed releases. The lesson: refactor toward SOLID incrementally, as pain points arise. Changing code that 'works' is risk without reward. Measure success by faster feature delivery, not by number of interfaces.
Why SOLID Principles Exist: Stop Treating Them Like a Checklist
You've memorized the acronym. Great. Now forget it for a second and ask yourself why we even care.
SOLID exists because pain compounds. One tangled class today becomes a three-sprint refactor in six months. Every engineer who's touched a legacy system knows the smell: a god class that does everything, a change in one place that breaks three unrelated features, or a unit test that requires mocking half the infrastructure.
The principles aren't a purity test. They're a survival guide for code that lives longer than your Jira ticket. When you slap SOLID labels on everything without understanding the cost, you end up with abstract factories for a two-line function. But when you internalize the why — that coupling kills velocity and rigidity kills safety — you stop writing code that needs an archaeology degree to debug.
Use SOLID to answer one question: "Does this change feel cheaper than it should?" If yes, the principles point at the hot spot. They're a diagnostic, not a prescription.
Start with the Single Responsibility Principle not because it's first, but because it's the one most devs get wrong. A class with "one reason to change" doesn't mean one method. It means one stakeholder or axis of change. Don't split until you feel the pain of a combined responsibility.
When to Apply SOLID Principles: The 48-Hour Rule
Here's the truth most tutorials skip: SOLID principles have a shelf life. Apply them in the first 48 hours of a feature — when the shape is still liquid — and you get clean architecture. Apply them six months later to a 10,000-line module and you get a rewrite disguised as a refactor.
Timing matters because SOLID is prophylactic, not curative. The Open/Closed Principle works best when you're adding a third behavior to a known pattern. The Dependency Inversion Principle saves your ass when you're integrating with an external API that changes quarterly. But if you're retrofitting these into stable, tested code, you're now fighting both the business logic and the existing tests.
The rule of thumb: Use SOLID when you anticipate change. Not when you've already been burned by it. Look for seams — new payment provider, third report format, second database vendor. That's where SOLID pays its rent. For throwaway prototypes or scripts that run once? Skip it. You're not building a cathedral for a one-off cron job.
Production code isn't a museum. The best code is the code that's easy to delete and rewrite when the requirements inevitably shift.
Conclusion: SOLID as a Decision Framework, Not a Religion
After exploring each principle—from Single Responsibility to Dependency Inversion—it becomes clear that SOLID is not a silver bullet. Over-applying these principles leads to premature abstraction, unnecessary indirection, and code that is harder to maintain than the original. The real lesson is context: SOLID helps most when change is frequent, when the system is expected to evolve for years, or when multiple teams own different modules. In prototypes, scripts, or stable infrastructure, the overhead may not be justified. Common pitfalls include treating Interface Segregation as a mandate to create many tiny interfaces, resulting in 'interface soup,' or applying Open/Closed by adding layers of abstraction for every feature, when composition could suffice. The closing takeaway is pragmatic: use SOLID to reveal design, not obscure it. When a change feels painful, ask whether a principle is being violated—but also ask whether the cost of fixing it outweighs future savings. Good engineers know when to follow rules; great engineers know when to break them intentionally.
The UserService That Did Everything and Broke Everything
- SRP violations make every change a 'touch the whole class' gamble.
- Ask: 'Which team would request this change?' before you write a new method on an existing class.
- The cost of splitting classes early is far less than the cost of production outages later.
- Pro tip: Use git blame to find classes with contributors from multiple teams — that's your SRP radar.
MySQLUserRepository()) directlygit log --follow --format='%an' -- <filename> | sort | uniq -c | sort -rnSplit the class: move user-facing formatting to UserEmailFormatter, persistence to UserRepository.Key takeaways
Common mistakes to avoid
5 patternsConfusing SRP with 'one method per class'
Applying OCP by wrapping every class in an interface by default
UserServiceImpl implements UserService where UserService has exactly one implementation and probably always will, adding indirection without value.Treating DIP as 'just use Spring'
Using inheritance when composition would satisfy LSP/ISP better
Assuming SOLID compliance means no coupling at all
Interview Questions on This Topic
Can you walk me through a real situation where you refactored code to follow the Single Responsibility Principle? What triggered the refactor and what was the measurable improvement?
UserManager class that handled authentication, email formatting, and database persistence. Every new feature required touching this class — authentication changes broke email formats and vice versa. A simple password reset feature took two weeks because of regressions. We refactored by extracting UserAuthenticator, UserEmailFormatter, and UserRepository. Each class had one responsibility. The measurable improvement: feature velocity doubled — a similar change that took two weeks now took three days. Regression bugs dropped by 70% in that module.Frequently Asked Questions
20+ years shipping production systems from the metal up. Notes here come from systems that actually shipped.
That's Software Engineering. Mark it forged?
16 min read · try the examples if you haven't