SOLID Principles — How SRP Violation Broke Order Processing
NullPointerExceptions from an email template cascaded through order pipeline — all from a single SRP violation.
- 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.
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.
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()) directlyKey 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
That's Software Engineering. Mark it forged?
12 min read · try the examples if you haven't