Home CS Fundamentals SOLID Principles Explained — With Real-World Java Examples

SOLID Principles Explained — With Real-World Java Examples

In Plain English 🔥
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.
⚡ Quick Answer
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.

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.

SingleResponsibilityExample.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// 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?' TestFor 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.

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.

OpenClosedLiskovExample.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// 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 TrapIf 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.

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.

InterfaceSegregationDIPExample.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
// ─── 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 InjectionInterviewers 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.
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

  • SRP is about one 'reason to change' — ask 'which team owns this?' If two teams could independently change a class, split it.
  • 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.
  • 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.
  • 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.

⚠ Common Mistakes to Avoid

  • Mistake 1: Confusing SRP with 'one method per class' — Symptoms: 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.
  • Mistake 2: Applying OCP by wrapping every class in an interface by default — Symptoms: a 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.
  • Mistake 3: Treating DIP as 'just use Spring' — Symptoms: 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.

Interview Questions on This Topic

  • QCan 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?
  • QHow 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?
  • QHere'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?

Frequently Asked Questions

Do I need to apply all five SOLID principles in every project?

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.

What's the difference between the Open/Closed Principle and just using inheritance?

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.

How do SOLID principles relate to design patterns like Strategy or Factory?

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.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousAgile and Scrum ExplainedNext →Design Patterns Overview
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged