Senior 12 min · March 06, 2026

SOLID Principles — How SRP Violation Broke Order Processing

NullPointerExceptions from an email template cascaded through order pipeline — all from a single SRP violation.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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.

SingleResponsibilityExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 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);
        }
    }
}
Output
Welcome, alice! Your account email is alice@example.com
Saving user to DB: username=alice, email=alice@example.com
The 'Who Asks For This Change?' Test
For any class you write, ask yourself: 'Which team or role would ask me to change this?' If more than one team could independently request a change — split the class. This mental test catches SRP violations faster than any static analysis tool.
Production Insight
In production, SRP violations show up as mysterious regressions.
A single deploy touches validation, formatting, and persistence — all in one file.
Fix: enforce a rule — each class must be owned by exactly one team's domain.
Real example: a change to email copy accidentally nulled a field that persistence needed, crashing the entire pipeline.
Key Takeaway
SRP is about ownership, not method count.
Split when two different actors have a reason to change the same class.

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.

OpenClosedLiskovExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// 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);
    }
}
Output
Charging card ending in 4242: $99.99
-> Result: Credit card charged successfully
PayPal transfer to alice@example.com: $49.5
-> Result: PayPal payment sent
Broadcasting crypto tx to 0xABCDEF123456: $250.0
-> Result: Crypto transaction broadcast
Watch Out: The Square/Rectangle LSP Trap
If you ever find yourself writing 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.
Production Insight
Giant switch statements are the #1 sign of OCP violations.
Each new case duplicates risk across every branch — one rogue change anywhere breaks all.
Fix: extract the behaviour behind an interface; each new feature is a new class, not a new case.
LSP violations cause the hardest-to-debug failures: a subclass that 'works' until it doesn't, because the caller assumed a contract it broke.
Key Takeaway
OCP: Add behaviour by writing new code, not editing old code.
LSP: If you need instanceof, you've broken the contract.

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(), swim(), and fly(), then your Dog class is forced to implement fly() — which makes no sense. The fix is to split 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 UnsupportedOperationException() in an interface implementation — that's a class screaming that it was forced to sign a contract it can't honour.

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.

InterfaceSegregationDIPExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// ─── 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());
    }
}
Output
=== ISP Demo ===
[BasicPrinter] Printing: Q4 Financial Report
[Enterprise] Printing: Contract Draft
[Enterprise] Scanning: Signed Contract
[Enterprise] Faxing 'Signed Contract' to +1-555-0199
=== DIP Demo — Production ===
[SMTP] Sending email to alice@example.com
Subject: Order Confirmed: #ORD-8821
Body: Your order #ORD-8821 has been confirmed. Thank you!
Order ORD-8821 confirmation processed.
=== DIP Demo — Unit Test Scenario ===
[TEST STUB] Pretending to send email to bob@example.com
Order ORD-8822 confirmation processed.
Test verified recipient: bob@example.com
Interview Gold: DIP ≠ Dependency Injection
Interviewers love this one. DIP is the principle — the rule that you should depend on abstractions. Dependency Injection (DI) is one technique to achieve it. You can satisfy DIP using a DI framework like Spring, but you can also satisfy it manually with constructor injection as shown above. Saying they're the same thing in an interview is a red flag for the interviewer.
Production Insight
Fat interfaces that force UnsupportedOperationException are ticking time bombs.
Every time a new consumer needs only part of the interface, you risk breaking existing implementations.
Fix: design lean interfaces per capability — let classes implement multiple small interfaces.
DIP violations make your architecture rigid: you can't swap implementations without rewriting business logic.
Key Takeaway
ISP: No class should be forced to implement a method it doesn't need.
DIP: Depend on abstractions, not concretions — your tests will thank you.

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.

SOLIDOverEngineering.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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);
}
Output
No output — compilation or runtime behaviour differs per approach.
SOLID as a Spectrum
  • 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'.
Production Insight
Over-engineering is more dangerous than under-engineering.
A codebase with 15 micromodules takes longer to debug than one with 3 fat classes.
Fix: apply SOLID where it reduces risk, not where it adds ceremony.
The signal that SOLID is hurting: you spend more time navigating abstractions than writing business logic.
Key Takeaway
SOLID is a tool, not a rulebook.
Apply it where it reduces pain, not where it adds complexity.
When to Apply SOLID vs When to Hold Off
IfCodebase is a script or prototype with < 5 developers
UseSkip strict SOLID — focus on readability and correctness first.
IfClass is changed by multiple teams within same month
UseApply SRP immediately — split by ownership.
IfNew requirement is the 2nd variant of an existing pattern (e.g., 2nd payment type)
UseApply OCP: extract interface and add new implementation.
IfSubclass behaviour differs from parent in ways that break callers
UseRefactor inheritance to composition or split interfaces (LSP+ISP).
IfUnit tests require spinning up real database or email server
UseApply DIP: add an interface for the dependency and inject a test double.

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.

SOLIDModernJava.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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));
    }
}
Output
Charging card ending in 4242: $99.99
Result: Credit card charged.
PayPal to alice@example.com: $49.5
Result: PayPal processed.
Sealed Classes: Not a Silver Bullet
Sealed classes are great when you own the hierarchy and the set of subclasses is fixed. But for plugin architectures where external libraries provide implementations, ordinary interfaces remain the better choice. The sealed keyword prevents external extension — use it selectively.
Production Insight
Using records for DIP abstractions removes entire classes of bugs.
No accidental mutable state leaking across threads.
But beware: records are no substitute for proper abstraction — a record with ten fields is still an SRP violation in data form.
Key Takeaway
Modern Java features can make SOLID implementations cleaner.
But the principles themselves are language-agnostic — don't let syntactic sugar replace judgment.

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.

SOLIDTradeoffsExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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.
Output
No runtime output — illustrates decision-making.
The 'Good Enough' Rule
Perfect design is the enemy of shipped software. A class that violates SRP but is stable and small is better than a perfectly decoupled system that never ships. Aim for 'good enough to change safely' not 'architecturally pristine'.
Production Insight
The worst trade-off is applying SOLID to everything equally.
Core domain logic deserves more rigor than peripheral logging or config parsing.
Rule: apply SOLID in proportion to the cost of changing that component.
The sweet spot: refactor toward SOLID when you feel the pain of a change — not before.
Key Takeaway
SOLID is a decision framework, not a checklist.
Let the cost of change guide how strictly you apply each principle.
● Production incidentPOST-MORTEMseverity: high

The UserService That Did Everything and Broke Everything

Symptom
After deploying a welcome email template update, the entire order processing pipeline started throwing NullPointerExceptions. All orders stuck in 'pending' status.
Assumption
The team assumed the email formatting change was isolated — they only touched one method in a class called UserManager. No dependency mapping was done.
Root cause
UserManager handled validation, email formatting, persistence, and orchestration. The email formatting change accidentally introduced a null email field, which cascaded into the persistence and orchestration logic that shared the same class. The SRP violation meant a single change in one responsibility broke all others.
Fix
Split UserManager into UserEmailValidator, UserWelcomeFormatter, and UserRepository. Each class had one reason to change. The email formatting change now only touched UserWelcomeFormatter — zero risk to other flows.
Key lesson
  • 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.
Production debug guideUse these symptom→action pairs to spot violations before they reach production.5 entries
Symptom · 01
Class name contains 'Manager', 'Processor', 'Service' doing 3+ different things (validation, formatting, persistence, logging)
Fix
Apply SRP: split by actor/team. Use 'Who asks for this change?' test.
Symptom · 02
One switch/if-else statement grows every month with new payment methods, report types, or notification channels
Fix
Apply OCP: extract an interface (PaymentMethod, ReportGenerator, Notifier) and add new implementations without editing existing ones.
Symptom · 03
Subclass overrides parent method to throw UnsupportedOperationException or change behaviour in unexpected ways (e.g., Square.setWidth also sets height)
Fix
LSP violation. Redesign hierarchy — prefer composition over inheritance. Split the base class into more specific contracts.
Symptom · 04
A concrete class implements a large interface but throws UnsupportedOperationException for half the methods
Fix
ISP violation. Break the large interface into smaller, focused ones (e.g., Printable, Scannable, Faxable). Only implement what you need.
Symptom · 05
High-level business logic class imports and instantiates a concrete low-level dependency (e.g., new MySQLUserRepository()) directly
Fix
DIP violation. Depend on an interface (UserRepository). Inject the implementation via constructor — makes testing and swapping possible.
★ SOLID Violation Debugging Cheat SheetQuick steps to diagnose and fix each SOLID violation during code review or when debugging production regressions.
One class is changed by multiple teams — merge conflicts and regressions are common
Immediate action
Check git blame: if 3+ committers from different teams touched the file in the last month, it's an SRP violation.
Commands
git log --follow --format='%an' -- <filename> | sort | uniq -c | sort -rn
Split the class: move user-facing formatting to UserEmailFormatter, persistence to UserRepository.
Fix now
Extract the single most volatile responsibility into its own class and delegate via composition.
New business requirement requires editing a switch statement in a tested class+
Immediate action
Stop: do not edit the switch. Clone the codebase branch and extract the behaviour into an interface.
Commands
grep -rn 'switch.*case\|if.*instanceof' src/main/java/io/thecodeforge/ | head -20
Introduce an interface with the polymorphic method, implement for each case, then replace the switch with a Map or lambda dispatch.
Fix now
Make the original class accept the interface via constructor, inject a new implementation without touching the class.
Subclass throws UnsupportedOperationException or behaves differently when used as parent type+
Immediate action
Search for 'UnsupportedOperationException' in subclass. Count instanceof checks in callers.
Commands
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.
Fix now
Remove the offending subclass's inheritance, make it implement only the interfaces it actually supports. Update callers to depend on smaller interfaces.
A class implements a large interface and has many empty or exception-throwing methods+
Immediate action
List all methods in the interface and check which ones throw or are empty using code search.
Commands
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.
Fix now
Create Printable, Scannable, Faxable; let your class implement only Printable and Scannable. Remove the empty method stubs.
High-level service class calls 'new ConcreteRepository()' directly — unit tests require a real database+
Immediate action
Search for 'new' keyword followed by repository or service class names in business logic classes.
Commands
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).
Fix now
Extract the database dependency behind an interface, inject via constructor. Your tests now run without a database.
SOLID Principles at a Glance
PrincipleCore Question to AskClassic ViolationFix Pattern
Single ResponsibilityHow many reasons could this class change?UserManager handles auth, email, and DB persistenceSplit into UserAuthenticator, UserEmailer, UserRepository
Open/ClosedCan I add behaviour without editing existing code?Giant if/else or switch growing with each new typeAbstract interface + new concrete implementations
Liskov SubstitutionCan every subclass replace its parent without surprises?Square extends Rectangle breaks setWidth/setHeight contractRedesign hierarchy or restrict the parent interface
Interface SegregationIs any class forced to implement methods it doesn't use?Printer implements fax() and scan() it doesn't supportSplit fat interface into focused, single-purpose interfaces
Dependency InversionDoes high-level code know about concrete low-level classes?OrderService contains new MySQLRepository() directlyInject an abstraction via constructor or setter

Key takeaways

1
SRP is about one 'reason to change'
ask 'which team owns this?' If two teams could independently change a class, split it.
2
OCP + polymorphism is a pair
define an abstraction once, extend by adding new implementations — never by editing the switch statement that should have been a polymorphic call.
3
A thrown UnsupportedOperationException in an interface implementation is almost always an ISP or LSP violation disguised as a quick fix
it's technical debt with a ticking clock.
4
DIP is the principle that makes unit testing at scale possible
when your business logic depends on abstractions, you can inject fakes in tests and never touch a real database or mail server.
5
SOLID is a tool, not a checklist. Apply it where it reduces change risk, not where it adds ceremony.

Common mistakes to avoid

5 patterns
×

Confusing SRP with 'one method per class'

Symptom
Developers split classes so aggressively that a single feature requires 15 tiny classes with no clear ownership, making debugging a maze.
Fix
SRP is about one REASON TO CHANGE (one owning actor), not one method. A class can have 10 methods as long as they all serve the same single business concern.
×

Applying OCP by wrapping every class in an interface by default

Symptom
Codebase full of UserServiceImpl implements UserService where UserService has exactly one implementation and probably always will, adding indirection without value.
Fix
Apply OCP where you have genuine variation points — places where you know or strongly suspect multiple implementations will exist. Don't pre-abstract everything; wait for the second concrete use case before introducing the abstraction.
×

Treating DIP as 'just use Spring'

Symptom
Developers inject Spring's @Autowired everywhere but still write high-level service classes that reference concrete repository classes directly in their logic (e.g. casting to MySQLUserRepository to call a MySQL-specific method).
Fix
DIP is about what your code DEPENDS ON conceptually. Your service methods should only call methods defined on the interface. If you're casting or calling concrete-class-specific methods, you've bypassed DIP regardless of whether a DI framework wired it up.
×

Using inheritance when composition would satisfy LSP/ISP better

Symptom
Classes deep in an inheritance hierarchy that override many methods, often throwing UnsupportedOperationException or having empty bodies.
Fix
Prefer composition over inheritance. Break the superclass into small capability interfaces (e.g., Printable, Scannable) and compose the object with exactly the capabilities it needs.
×

Assuming SOLID compliance means no coupling at all

Symptom
Teams spend weeks decoupling classes that will never need to be independent, delaying feature delivery.
Fix
Coupling is acceptable in modules that change together. The goal is to decouple modules that change for different reasons — not to eliminate all coupling.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Can you walk me through a real situation where you refactored code to fo...
Q02SENIOR
How would you explain the difference between the Dependency Inversion Pr...
Q03SENIOR
Here's a scenario: you have a `Bird` interface with a `fly()` method, an...
Q04SENIOR
What's the relationship between the Open/Closed Principle and the use of...
Q05SENIOR
If you see a codebase with many `*Manager` classes, each with 20+ method...
Q01 of 05SENIOR

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?

ANSWER
In one project, we had a 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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Do I need to apply all five SOLID principles in every project?
02
What's the difference between the Open/Closed Principle and just using inheritance?
03
How do SOLID principles relate to design patterns like Strategy or Factory?
04
What's the most common SOLID violation in production Java codebases?
🔥

That's Software Engineering. Mark it forged?

12 min read · try the examples if you haven't

Previous
Agile and Scrum Explained
3 / 16 · Software Engineering
Next
Design Patterns Overview: Creational, Structural and Behavioural