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 grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
SOLID is a mnemonic for five object-oriented design principles—Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—that aim to make software more maintainable, testable, and resilient to change. They were distilled by Robert C.
Martin in the early 2000s from decades of OOP practice, and they directly address the fragility that emerges when classes accumulate too many responsibilities or depend too tightly on concrete implementations. In practice, SOLID forces you to decompose systems into cohesive, loosely coupled modules where each piece has a clear job and can be swapped or extended without rewriting everything around it.
The principles are language-agnostic but most commonly applied in statically typed OOP languages like Java, C#, and TypeScript.
These principles aren't a silver bullet—they trade simplicity for flexibility, and over-applying them can lead to needless abstraction and indirection (the 'interface explosion' anti-pattern). You should reach for SOLID when you're building a system that will live for years, be maintained by multiple teams, or needs to accommodate unpredictable requirements.
For throwaway scripts, prototypes, or tiny codebases, the overhead often isn't worth it. Alternatives like functional programming with pure functions and algebraic data types solve similar problems with different trade-offs, and many modern frameworks (Spring, ASP.NET Core) bake SOLID-aligned patterns into their architecture, so you get the benefits without writing all the glue yourself.
Concretely, violating the Single Responsibility Principle (SRP) is the most common source of production incidents I've seen in order processing systems—when a single class handles validation, persistence, email notifications, and logging, a change to the email template can accidentally break order submission. SOLID's real value is in isolating those failure domains.
In modern Java, features like sealed classes and records make it easier to adhere to Open/Closed and Interface Segregation without boilerplate, while pattern matching reduces the need for the visitor pattern that Liskov Substitution often demands. The principles remain relevant because they codify what experienced engineers do instinctively: separate concerns, program to interfaces, and design for the change you know is coming.
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.
// BEFORE: This class violates SRP — it handles user data, email formatting, // AND database persistence. Three different reasons to change. class UserManagerBad { private String username; private String email; public UserManagerBad(String username, String email) { this.username = username; this.email = email; } // Reason 1: Business logic team asks to change validation rules public boolean isValidEmail() { return email.contains("@") && email.contains("."); } // Reason 2: UI/comms team asks to change welcome message copy public String formatWelcomeMessage() { return "Welcome, " + username + "! Your account email is " + email; } // Reason 3: Database team asks to change persistence format public void saveToDatabase() { System.out.println("INSERT INTO users VALUES ('" + username + "', '" + email + "')"); } } // AFTER: Three classes, each with one clear reason to change. // Owned by: validation/business rules team class UserEmailValidator { public boolean isValid(String email) { // Only this class changes when email validation rules change return email != null && email.contains("@") && email.contains("."); } } // Owned by: communications/UI team class UserWelcomeFormatter { public String format(String username, String email) { // Only this class changes when welcome message copy changes return "Welcome, " + username + "! Your account email is " + email; } } // Owned by: persistence/database team class UserRepository { public void save(String username, String email) { // Only this class changes when the storage mechanism changes System.out.println("Saving user to DB: username=" + username + ", email=" + email); } } // Coordinator: thin orchestration — no logic of its own public class SingleResponsibilityExample { public static void main(String[] args) { String username = "alice"; String email = "alice@example.com"; UserEmailValidator validator = new UserEmailValidator(); UserWelcomeFormatter formatter = new UserWelcomeFormatter(); UserRepository repository = new UserRepository(); if (validator.isValid(email)) { // Each collaborator does exactly one job System.out.println(formatter.format(username, email)); repository.save(username, email); } else { System.out.println("Invalid email address: " + email); } } }
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 method that threw send()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.
// OCP VIOLATION: Every new payment method requires editing this tested class. class PaymentProcessorBad { public void process(String paymentType, double amount) { if (paymentType.equals("CREDIT_CARD")) { System.out.println("Charging credit card: $" + amount); } else if (paymentType.equals("PAYPAL")) { System.out.println("Sending PayPal request: $" + amount); } // Every new payment method means EDITING this method — risky and fragile. } } // OCP + LSP COMPLIANT DESIGN: // The abstraction — the contract that ALL payment methods must honour. interface PaymentMethod { // LSP guarantee: any class implementing this MUST process the amount // and return a result. No silent failures, no unexpected exceptions. PaymentResult process(double amount); } // The result object — makes success/failure explicit rather than relying on exceptions class PaymentResult { private final boolean success; private final String message; public PaymentResult(boolean success, String message) { this.success = success; this.message = message; } public boolean isSuccess() { return success; } public String getMessage() { return message; } } // Extension 1: Added without touching PaymentMethod or any other class class CreditCardPayment implements PaymentMethod { private final String maskedCardNumber; public CreditCardPayment(String maskedCardNumber) { this.maskedCardNumber = maskedCardNumber; } @Override public PaymentResult process(double amount) { // Honours the contract: always returns a PaymentResult, never throws unexpectedly System.out.println("Charging card ending in " + maskedCardNumber + ": $" + amount); return new PaymentResult(true, "Credit card charged successfully"); } } // Extension 2: Added without touching existing code — OCP in action class PayPalPayment implements PaymentMethod { private final String paypalEmail; public PayPalPayment(String paypalEmail) { this.paypalEmail = paypalEmail; } @Override public PaymentResult process(double amount) { System.out.println("PayPal transfer to " + paypalEmail + ": $" + amount); return new PaymentResult(true, "PayPal payment sent"); } } // Extension 3: A new payment type added MONTHS later — zero changes to existing classes class CryptoPayment implements PaymentMethod { private final String walletAddress; public CryptoPayment(String walletAddress) { this.walletAddress = walletAddress; } @Override public PaymentResult process(double amount) { System.out.println("Broadcasting crypto tx to " + walletAddress + ": $" + amount); return new PaymentResult(true, "Crypto transaction broadcast"); } } // The processor is now permanently closed for modification. // You NEVER need to edit this class again, regardless of new payment methods. class PaymentProcessor { public void process(PaymentMethod paymentMethod, double amount) { // LSP guarantee: we call process() on any PaymentMethod — no instanceof needed. PaymentResult result = paymentMethod.process(amount); if (result.isSuccess()) { System.out.println(" -> Result: " + result.getMessage()); } else { System.out.println(" -> FAILED: " + result.getMessage()); } } } public class OpenClosedLiskovExample { public static void main(String[] args) { PaymentProcessor processor = new PaymentProcessor(); // Each of these is substitutable — LSP is satisfied. // The processor doesn't know or care which implementation it's using. processor.process(new CreditCardPayment("4242"), 99.99); processor.process(new PayPalPayment("alice@example.com"), 49.50); processor.process(new CryptoPayment("0xABCDEF123456"), 250.00); } }
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.
// ─── INTERFACE SEGREGATION ─────────────────────────────────────────────────── // VIOLATION: A fat interface forces PrinterDevice to implement fax and scan // even if the device is a basic printer that can't do either. interface MultifunctionDeviceBad { void print(String document); void scan(String document); // Basic printers can't scan! void fax(String document); // Basic printers can't fax! } // FIX: Segregate into small, focused interfaces. // Each interface represents ONE capability. interface Printable { void print(String document); } interface Scannable { void scan(String document); } interface Faxable { void fax(String phoneNumber, String document); } // A basic printer only implements what it actually supports. class BasicPrinter implements Printable { @Override public void print(String document) { // No forced stub methods, no UnsupportedOperationException hacks System.out.println("[BasicPrinter] Printing: " + document); } } // An enterprise device implements everything it genuinely supports. class EnterpriseMultifunctionPrinter implements Printable, Scannable, Faxable { @Override public void print(String document) { System.out.println("[Enterprise] Printing: " + document); } @Override public void scan(String document) { System.out.println("[Enterprise] Scanning: " + document); } @Override public void fax(String phoneNumber, String document) { System.out.println("[Enterprise] Faxing '" + document + "' to " + phoneNumber); } } // ─── DEPENDENCY INVERSION ───────────────────────────────────────────────────── // The abstraction — business logic depends on THIS, not on a concrete database class. interface NotificationSender { void send(String recipientEmail, String subject, String body); } // LOW-LEVEL detail: a concrete implementation of the abstraction. // This class knows about SMTP — the business logic class does NOT. class SmtpEmailSender implements NotificationSender { @Override public void send(String recipientEmail, String subject, String body) { // In real code this would use JavaMail. Here we simulate it. System.out.println("[SMTP] Sending email to " + recipientEmail); System.out.println(" Subject: " + subject); System.out.println(" Body: " + body); } } // A test double — swapped in during unit tests so we don't need a real mail server. class FakeNotificationSender implements NotificationSender { private String lastRecipient; // Stored so tests can assert on it @Override public void send(String recipientEmail, String subject, String body) { this.lastRecipient = recipientEmail; System.out.println("[TEST STUB] Pretending to send email to " + recipientEmail); } public String getLastRecipient() { return lastRecipient; } } // HIGH-LEVEL business logic: knows nothing about SMTP, HTTP, or any concrete sender. // The dependency is injected — this class is testable in isolation. class OrderConfirmationService { // Depends on the abstraction, NOT on SmtpEmailSender directly — DIP satisfied. private final NotificationSender notificationSender; // Dependency injected via constructor — easy to swap for tests public OrderConfirmationService(NotificationSender notificationSender) { this.notificationSender = notificationSender; } public void confirmOrder(String customerEmail, String orderId) { // Business logic lives here — not delivery mechanism details String subject = "Order Confirmed: #" + orderId; String body = "Your order #" + orderId + " has been confirmed. Thank you!"; notificationSender.send(customerEmail, subject, body); System.out.println("Order " + orderId + " confirmation processed."); } } public class InterfaceSegregationDIPExample { public static void main(String[] args) { System.out.println("=== ISP Demo ==="); BasicPrinter basicPrinter = new BasicPrinter(); basicPrinter.print("Q4 Financial Report"); // Works cleanly, no stub methods EnterpriseMultifunctionPrinter mfp = new EnterpriseMultifunctionPrinter(); mfp.print("Contract Draft"); mfp.scan("Signed Contract"); mfp.fax("+1-555-0199", "Signed Contract"); System.out.println(); System.out.println("=== DIP Demo — Production ==="); // In production: inject the real SMTP sender OrderConfirmationService prodService = new OrderConfirmationService(new SmtpEmailSender()); prodService.confirmOrder("alice@example.com", "ORD-8821"); System.out.println(); System.out.println("=== DIP Demo — Unit Test Scenario ==="); // In tests: inject the fake — no mail server required FakeNotificationSender fakeSender = new FakeNotificationSender(); OrderConfirmationService testService = new OrderConfirmationService(fakeSender); testService.confirmOrder("bob@example.com", "ORD-8822"); System.out.println("Test verified recipient: " + fakeSender.getLastRecipient()); } }
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.
// Anti-pattern 2: Preemptive abstraction — interface with single implementation // Now you have to maintain two files for no benefit. interface UserService { User findUserById(int id); } class UserServiceImpl implements UserService { @Override public User findUserById(int id) { // Only one implementation exists — the interface adds zero value. return new User(id, "alice"); } } // Better: wait for the second implementation // When a CachedUserService or ReadReplicaUserService is needed, introduce the interface. class UserService { public User findUserById(int id) { return new User(id, "alice"); } } // Anti-pattern 3: DIP bypassed via casting class OrderService { @Autowired private UserRepository userRepository; // interface injected public void processOrder() { // The interface doesn't have this method — we cast to concrete. // This breaks DIP: now OrderService depends on MySQLUserRepository directly. ((MySQLUserRepository) userRepository).executeNativeQuery("SELECT ..."); } } // Correct approach: add the method to the interface or create a new abstraction. interface UserRepository { User findById(int id); // If native query is needed, expose it via the interface. List<Map<String, Object>> executeNativeQuery(String sql); }
- 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.
package io.thecodeforge.solid; import java.math.BigDecimal; // Sealed interface — OCP: closed for modification, open for extension via permitted subtypes. // The compiler knows exactly which implementations exist, enabling exhaustive pattern matching. public sealed interface PaymentMethod permits CreditCard, PayPal, Crypto { PaymentResult process(BigDecimal amount); } // Records are ideal for DIP — they carry data, have no side effects, and are inherently immutable. public record PaymentResult(boolean success, String message) {} public final class CreditCard implements PaymentMethod { private final String maskedCardNumber; public CreditCard(String maskedCardNumber) { this.maskedCardNumber = maskedCardNumber; } @Override public PaymentResult process(BigDecimal amount) { System.out.println("Charging card ending in " + maskedCardNumber + ": $" + amount); return new PaymentResult(true, "Credit card charged."); } } public final class PayPal implements PaymentMethod { private final String email; public PayPal(String email) { this.email = email; } @Override public PaymentResult process(BigDecimal amount) { System.out.println("PayPal to " + email + ": $" + amount); return new PaymentResult(true, "PayPal processed."); } } public final class Crypto implements PaymentMethod { private final String walletAddress; public Crypto(String walletAddress) { this.walletAddress = walletAddress; } @Override public PaymentResult process(BigDecimal amount) { System.out.println("Crypto tx to " + walletAddress + ": $" + amount); return new PaymentResult(true, "Crypto broadcast."); } } // The processor uses pattern matching — no instanceof checks, no manual dispatch. // This is still OCP-compliant: the sealed interface is closed, but new types can be added // by adding a new permitted class (only possible if you own the hierarchy). class PaymentProcessor { public void process(PaymentMethod payment, BigDecimal amount) { PaymentResult result = switch (payment) { case CreditCard cc -> cc.process(amount); case PayPal pp -> pp.process(amount); case Crypto cr -> cr.process(amount); }; System.out.println("Result: " + result.message()); } } public class SOLIDModernJava { public static void main(String[] args) { PaymentProcessor processor = new PaymentProcessor(); processor.process(new CreditCard("4242"), BigDecimal.valueOf(99.99)); processor.process(new PayPal("alice@example.com"), BigDecimal.valueOf(49.50)); } }
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.
package io.thecodeforge.solid.tradeoffs; // Trade-off example: When to add a DIP abstraction? // Stage 1: No abstraction — fine for a solo service class OrderNotificationService { public void sendConfirmation(String email, String orderId) { // Uses JavaMail directly System.out.println("Sending email to " + email + " for order " + orderId); } } // Stage 2: Second requirement arrives — now abstraction makes sense class OrderNotificationService { private final EmailSender emailSender; public OrderNotificationService(EmailSender emailSender) { this.emailSender = emailSender; } public void sendConfirmation(String email, String orderId) { emailSender.send(email, "Order Confirmed", "Your order #" + orderId); } } interface EmailSender { void send(String to, String subject, String body); } class SmtpEmailSender implements EmailSender { @Override public void send(String to, String subject, String body) { // SMTP logic } } class MockEmailSender implements EmailSender { @Override public void send(String to, String subject, String body) { // Test debug } } // The abstraction came when the second implementation was needed, not before.
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.
// io.thecodeforge — cs-fundamentals tutorial # SRP violation: This class changes for different reasons class ReportGenerator: def generate_report(self, data): # Business logic: formatting numbers formatted = [{"value": round(d["amount"], 2)} for d in data] return formatted def export_json(self, report): # Serialization logic: outputs JSON import json return json.dumps(report) def send_email(self, report): # Infrastructure logic: sends email import smtplib # ... 20 lines of SMTP config ... pass # Not the point # Quick test to see the pain: generator = ReportGenerator() raw_data = [{"amount": 123.4567}, {"amount": 89.0}] report = generator.generate_report(raw_data) print(generator.export_json(report))
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.
// io.thecodeforge — cs-fundamentals tutorial # BEFORE: Violates OCP — new notification type needs class modification class NotificationService: def send(self, message, channel): if channel == "email": # ... email logic return f"Email sent: {message}" elif channel == "sms": # ... sms logic return f"SMS sent: {message}" # else if slack: add another branch (BAD) # AFTER: OCP-friendly — extend via new classes, never modify existing from abc import ABC, abstractmethod class NotificationChannel(ABC): @abstractmethod def send(self, message: str) -> str: pass class EmailChannel(NotificationChannel): def send(self, message: str) -> str: return f"Email sent: {message}" class SMSChannel(NotificationChannel): def send(self, message: str) -> str: return f"SMS sent: {message}" # Adding Slack? New class, no changes to existing code class SlackChannel(NotificationChannel): def send(self, message: str) -> str: return f"Slack message: {message}" service = NotificationService() # Oops, not needed — inject the channel instead slack = SlackChannel() print(slack.send("deploy complete"))
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.
// io.thecodeforge — cs-fundamentals tutorial // Over-applied SOLID: too many interfaces, too much indirection class Logger(Protocol): pass class FileLogger(Logger): pass class CloudLogger(Logger): pass class LoggerFactory: pass class LoggerDecorator: pass # Sometimes a simple function suffices: def log(msg: str) -> None: print(f"{msg}") ✓ Keep it simple until abstraction pays off.
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.grep -rn 'switch.*case\|if.*instanceof' src/main/java/io/thecodeforge/ | head -20Introduce an interface with the polymorphic method, implement for each case, then replace the switch with a Map or lambda dispatch.grep -rn 'UnsupportedOperationException' src/main/java/io/thecodeforge/Redesign: split the base interface into smaller capability interfaces (e.g., Flyable, Walkable). Replace inheritance with composition.grep -rn 'throw new UnsupportedOperationException' src/main/java/io/thecodeforge/Split the fat interface into multiple focused interfaces using the Interface Segregation Principle. Have the class implement only the ones it needs.git grep 'new.*Repository(' src/main/java/io/thecodeforge/service/Introduce an interface for the repository, change constructor to accept the interface, create a test double (Mockito mock or stub).| Principle | Core Question to Ask | Classic Violation | Fix Pattern |
|---|---|---|---|
| Single Responsibility | How many reasons could this class change? | UserManager handles auth, email, and DB persistence | Split into UserAuthenticator, UserEmailer, UserRepository |
| Open/Closed | Can I add behaviour without editing existing code? | Giant if/else or switch growing with each new type | Abstract interface + new concrete implementations |
| Liskov Substitution | Can every subclass replace its parent without surprises? | Square extends Rectangle breaks setWidth/setHeight contract | Redesign hierarchy or restrict the parent interface |
| Interface Segregation | Is any class forced to implement methods it doesn't use? | Printer implements fax() and scan() it doesn't support | Split fat interface into focused, single-purpose interfaces |
| Dependency Inversion | Does high-level code know about concrete low-level classes? | OrderService contains new MySQLRepository() directly | Inject an abstraction via constructor or setter |
| File | Command / Code | Purpose |
|---|---|---|
| SingleResponsibilityExample.java | class UserManagerBad { | S |
| OpenClosedLiskovExample.java | class PaymentProcessorBad { | O & L |
| InterfaceSegregationDIPExample.java | interface MultifunctionDeviceBad { | I & D |
| SOLIDOverEngineering.java | interface UserService { | When SOLID Backfires |
| SOLIDModernJava.java | public sealed interface PaymentMethod permits CreditCard, PayPal, Crypto { | SOLID in Modern Java |
| SOLIDTradeoffsExample.java | class OrderNotificationService { | SOLID in Practice |
| DiagnoseViolation.py | class ReportGenerator: | Why SOLID Principles Exist |
| OpenClosed_BeforeAfter.py | class NotificationService: | When to Apply SOLID Principles |
| Ex.py | class Logger(Protocol): pass | Conclusion |
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.How would you explain the difference between the Dependency Inversion Principle and Dependency Injection to a junior developer? Can you give an example where DIP is satisfied WITHOUT a DI framework?
OrderService, you accept a PaymentGateway interface, not a concrete StripePaymentGateway. Then in main(), you do new OrderService(new StripePaymentGateway()) — that's manual dependency injection. The principle (DIP) is satisfied because OrderService only knows about the PaymentGateway interface, not the concrete class. A DI framework like Spring just automates that wiring.Here's a scenario: you have a `Bird` interface with a `fly()` method, and you need to add `Penguin` to your system. How do you handle this without violating Liskov Substitution or forcing Penguin to throw UnsupportedOperationException?
Bird interface with fly() is a fat interface that violates ISP. The fix is to split it into smaller interfaces: Flyable, Walkable, Swimmable. Penguin implements Walkable and Swimmable but not Flyable. That's clean ISP. To maintain LSP, any code that uses Flyable will only receive birds that can actually fly — no instanceof checks, no unsupported exceptions. The key: design interfaces around capabilities, not around real-world categories.What's the relationship between the Open/Closed Principle and the use of sealed classes in Java 17? When would you choose sealed classes over traditional interfaces?
Command pattern for a CLI). Use traditional interfaces when you expect third-party implementations or you don't control the subtype list. The trade-off: sealed classes make the contract tighter and increase coupling to the sealed hierarchy — but they provide better guarantees for LSP.If you see a codebase with many `*Manager` classes, each with 20+ methods and dependencies on databases, email, and logging, what's the first principle you'd check? What refactoring steps would you propose?
Frequently Asked Questions
Not rigidly. SOLID principles are guidelines, not laws. In a small script or a throwaway prototype, strict SOLID compliance adds overhead without payoff. The real value kicks in on codebases that will grow, be maintained by multiple developers, or need to be tested in isolation. Apply them where complexity justifies the structure — and that threshold is lower than most developers think.
OCP is the goal; inheritance and interfaces are tools to achieve it. Inheritance alone can actually violate OCP if a child class overrides parent behaviour in unexpected ways. The safest OCP implementation uses interfaces or abstract classes to define a stable contract, then creates new concrete classes for new behaviour — rather than modifying existing ones. Composition over inheritance is often the cleaner path.
Design patterns are recurring solutions that often embody SOLID principles by default. The Strategy pattern is a direct implementation of OCP and DIP — you define a strategy interface (abstraction) and swap concrete strategies without touching the context class. The Factory pattern helps you respect DIP by centralising the creation of concrete classes so your business logic never calls 'new ConcreteClass()' directly. Learning SOLID first makes design patterns click immediately — you'll see exactly which principle each pattern is enforcing.
The most common is SRP violation: classes named Service or Manager that accumulate methods for years. A typical OrderService might handle validation, calculation, persistence, email notification, and logging. It's changed by different teams for different reasons, creating constant merge conflicts and regressions. The second most common is DIP violation — classes hardcoding concrete dependencies like new , making unit testing nearly impossible.MySQLRepository()
20+ years shipping production systems from the metal up. Notes here come from systems that actually shipped.
That's Software Engineering. Mark it forged?
13 min read · try the examples if you haven't