SOLID Principles in Java: Complete Guide with Examples

SOLID Principles in Java: Complete Guide with Examples (2026)

SOLID Principles in Java: Complete Guide with Examples

Learn SOLID principles in Java with simple explanations and practical examples. This guide covers Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles with before-and-after code for beginners and experts.

Why SOLID Principles Still Matter in Java

Writing code that works is easy. Writing code that survives change is the real challenge.

Robert C. Martin introduced the SOLID principles to combat "software rot"—the tendency of codebases to become rigid, fragile, and difficult to maintain as they grow. These five principles provide a blueprint for building software that is:

  • Understandable — New developers can navigate the codebase faster
  • Flexible — Requirements change without breaking everything
  • Maintainable — Fixes and updates stay localized
  • Testable — Units can be verified in isolation

Whether you're preparing for interviews or architecting enterprise systems, SOLID isn't academic theory—it's practical wisdom distilled from decades of building software.

S — Single Responsibility Principle (SRP)

"A class should have one, and only one, reason to change."

What It Really Means

SRP isn't about having one method per class. It's about one reason to change. If your class serves two different stakeholders (e.g., accounting and HR), a change requested by one can break functionality for the other.

Violation: The Multi-Purpose Invoice

public class Invoice {
    private double amount;

    public double calculateTotal() {
        return amount * 1.18; // Business logic
    }

    public void saveToDatabase() {
        // Persistence logic
        System.out.println("Saving invoice...");
    }

    public void printReport() {
        // Presentation logic
        System.out.println("Printing report...");
    }
}

The Problem: Three reasons to change. Tax laws change → modify calculateTotal(). Database schema changes → modify saveToDatabase(). Report format changes → modify printReport(). One class, three stakeholders, endless fragility.

Solution: Separate the Concerns

class Invoice {
    private double amount;
    private double taxRate;

    public double calculateTotal() {
        return amount * (1 + taxRate);
    }
}

class InvoiceRepository {
    public void save(Invoice invoice) {
        // Only persistence logic lives here
    }
}

class InvoiceReportGenerator {
    public String generateReport(Invoice invoice) {
        return "Invoice total: " + invoice.calculateTotal();
    }
}

For Beginners: Think of SRP like a kitchen. The chef cooks. The dishwasher cleans. The server delivers. When one person does everything, mistakes happen.

For Experts: SRP aligns with bounded contexts in Domain-Driven Design. Each class becomes a natural seam for testing, mocking, and independent deployment.

O — Open/Closed Principle (OCP)

"Software entities should be open for extension, but closed for modification."

What It Really Means

You should be able to add new behavior without editing existing, tested code. The primary mechanism? Polymorphism through interfaces or abstract classes.

Violation: The Growing If-Else

public class NotificationService {
    public void sendNotification(String type, String message) {
        if (type.equals("EMAIL")) {
            System.out.println("Email: " + message);
        } else if (type.equals("SMS")) {
            System.out.println("SMS: " + message);
        }
        // Every new type means editing this method again
    }
}

The Problem: Every new notification channel (Push, Slack, WhatsApp) requires modifying sendNotification(). You risk breaking existing, working code with each change.

Solution: Strategy Pattern via Interfaces

interface NotificationChannel {
    void send(String message);
}

class EmailChannel implements NotificationChannel {
    public void send(String message) {
        System.out.println("Email: " + message);
    }
}

class SmsChannel implements NotificationChannel {
    public void send(String message) {
        System.out.println("SMS: " + message);
    }
}

class NotificationService {
    private final NotificationChannel channel;

    public NotificationService(NotificationChannel channel) {
        this.channel = channel;
    }

    public void notify(String message) {
        channel.send(message);
    }
}

Now adding PushChannel means writing one new class, not editing the service. The service is closed for modification but open for extension.

For Beginners: Imagine a power strip. You don't rewire the strip to add a new device—you just plug it in. Interfaces are the sockets.

For Experts: This is the foundation of the Strategy pattern. In Spring applications, this translates to autowired List<NotificationChannel> where the framework handles extension discovery.

L — Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."

What It Really Means

If your code works with a Bird, it should work with a Penguin—without surprises. Subclasses must honor the contract of their parent.

Violation: The Rectangle-Square Trap

class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int area() { return width * height; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        this.width = w;
        this.height = w; // Forces height to match!
    }
    @Override
    public void setHeight(int h) {
        this.width = h;
        this.height = h;
    }
}

// Client code expects Rectangle behavior
void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20; // FAILS for Square: returns 16
}

The Problem: A Square cannot substitute a Rectangle without breaking the caller's expectations. The subclass silently altered behavior.

Solution: Separate Abstractions

interface Shape {
    int area();
}

class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(int w, int h) {
        this.width = w;
        this.height = h;
    }

    public int area() { return width * height; }
}

class Square implements Shape {
    private final int side;

    public Square(int side) {
        this.side = side;
    }

    public int area() { return side * side; }
}

Neither class inherits problematic behavior from the other. Both can be used wherever a Shape is expected.

For Beginners: Don't force a round peg into a square hole—even if the peg's class extends the hole's class.

For Experts: LSP violations often manifest as instanceof checks or UnsupportedOperationException throws. These are code smells signaling an incorrect inheritance hierarchy.

I — Interface Segregation Principle (ISP)

"Clients should not be forced to depend on interfaces they do not use."

What It Really Means

Fat interfaces create forced dependencies. Splitting them into focused, role-based interfaces keeps code clean and prevents meaningless implementations.

Violation: The Fat Worker Interface

interface Worker {
    void work();
    void eat();
    void sleep();
}

class RobotWorker implements Worker {
    public void work() { System.out.println("Working..."); }
    public void eat() { throw new UnsupportedOperationException("Robots don't eat!"); }
    public void sleep() { throw new UnsupportedOperationException("Robots don't sleep!"); }
}

The Problem: RobotWorker is forced to implement methods that make no sense for it. The interface is polluted.

Solution: Focused Interfaces

interface Workable {
    void work();
}

interface Feedable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class HumanWorker implements Workable, Feedable, Sleepable {
    public void work() { System.out.println("Working..."); }
    public void eat() { System.out.println("Eating..."); }
    public void sleep() { System.out.println("Sleeping..."); }
}

class RobotWorker implements Workable {
    public void work() { System.out.println("Working..."); }
}

Each class implements only what it actually needs. No meaningless methods, no exceptions thrown for methods that should never exist.

For Beginners: Don't sign a contract for services you'll never use. Small, focused interfaces keep everyone honest.

For Experts: ISP reduces the ripple effect of interface changes. A change to Feedable only affects classes that actually feed—not every worker in the system.

D — Dependency Inversion Principle (DIP)

"High-level modules should not depend on low-level modules. Both should depend on abstractions."

What It Really Means

Your business logic (high-level) shouldn't be coupled to specific databases, APIs, or frameworks (low-level). Both should depend on interfaces you control.

Violation: Hard-Wired Dependencies

class MySQLDatabase {
    public void save(String data) {
        System.out.println("Saving to MySQL: " + data);
    }
}

class OrderService {
    private MySQLDatabase database = new MySQLDatabase(); // Tight coupling!

    public void placeOrder(String order) {
        database.save(order);
    }
}

The Problem: OrderService is permanently married to MySQLDatabase. Switching to PostgreSQL means editing the service. Testing requires a real database.

Solution: Depend on Abstractions

interface Database {
    void save(String data);
}

class MySQLDatabase implements Database {
    public void save(String data) {
        System.out.println("Saving to MySQL: " + data);
    }
}

class PostgreSQLDatabase implements Database {
    public void save(String data) {
        System.out.println("Saving to PostgreSQL: " + data);
    }
}

class OrderService {
    private final Database database;

    public OrderService(Database database) { // Injected, not created
        this.database = database;
    }

    public void placeOrder(String order) {
        database.save(order);
    }
}

Now OrderService depends on the Database abstraction. You can inject MySQL, PostgreSQL, or a mock for testing—without changing a single line of business logic.

For Beginners: Instead of soldering your lamp directly to the wall, plug it into a socket. You can change lamps without rewiring your house.

For Experts: This is the philosophical foundation of Spring's dependency injection. @Autowired is DIP in action. The framework becomes the "injector" that wires abstractions to implementations at runtime.

Putting It All Together: The Insurance Claim Example

The best way to understand SOLID is to see all five principles applied to one system.

System: Insurance claim processing for AUTO, HEALTH, and PROPERTY claims.

Principle Application
SRP ClaimValidator validates. ClaimService orchestrates. ClaimController handles HTTP.
OCP Adding TRAVEL claim type? Create TravelClaimProcessor. No existing code changes.
LSP All processors implement ClaimProcessor. Any can replace another in the pipeline.
ISP ClaimProcessor has one method: process(Claim). No fat interfaces.
DIP ClaimService depends on ClaimProcessor interface, injected via factory.

This architecture scales. New claim types, new validation rules, new notification channels—all added without touching the core.

Frequently Asked Questions

What are SOLID principles in Java?

SOLID is an acronym for five object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. They help developers create maintainable, flexible, and testable code.

Why should I learn SOLID as a beginner?

SOLID principles prevent you from learning bad habits. Code that violates SOLID becomes painful to change. Learning these principles early means writing code that's easier to debug, extend, and explain in interviews.

Do SOLID principles apply to Spring Boot?

Absolutely. Spring's dependency injection is DIP in practice. Spring Data repositories follow ISP. The framework encourages SOLID-aligned architecture.

Is SOLID always the answer?

No. SOLID principles are tools, not dogma. Over-applying them can lead to excessive abstraction and complex code. Use judgment. For small scripts or prototypes, strict SOLID might be overkill.

How do SOLID principles relate to design patterns?

SOLID principles are the why. Design patterns (Strategy, Factory, Observer) are the how. OCP leads to Strategy. DIP leads to dependency injection. SRP leads to separation of concerns.

Your SOLID Checklist

Before committing code, ask:

  • SRP: Does this class have more than one reason to change?
  • OCP: Will adding new behavior require editing existing, tested code?
  • LSP: Can every subclass replace its parent without surprises?
  • ISP: Is any class forced to implement methods it doesn't need?
  • DIP: Does high-level logic depend on concrete implementations?

Start small. Pick one principle. Refactor one class. The habit compounds.

Conclusion

SOLID principles aren't about perfection—they're about conscious design. They won't eliminate every bug or predict every future requirement. But they will make your codebase more resilient, readable, and ready for the changes that will come.

Master them not as rules, but as tools. Know when to apply them, when to relax them, and when they save your project from becoming the legacy nightmare everyone dreads.

Related topics: Java Stream API, Java Lambda Expressions, Java Optional Class, Java Functional Interfaces, Spring Boot Dependency Injection

No comments: