The SOLID Design Principles Explained

August 25, 2026 (3d ago)

SOLID

SOLID is an acronym for five design principles that makes software designs more understandable, flexible, and maintainable.

Single Responsibility Principle (SRP)

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

Bad Example:

class Order {
    void calculateTotal(){
        // calculate order
    }
 
    void savetoDB(){
        // save order to database
    }
 
    void sendEmail(){
        // send confirmation email
    }
}

This class has several responsibilities:

  1. Business logic -> calculate total
  2. Database -> save order
  3. Communication -> send email
  4. File/document generation -> invoice

Now imagine the email system changes from SMTP to an API. You have to modify Order. If the database changes from MySQL to MongoDB, you modify Order again. If invoice generation changes, you modify Order again. That’s a violation of SRP.

Applying SRP:

Separate the responsibilities:

class Order {
    void calculate(){
        // business logic
    }
}
 
class OrderRepository {
    void save(Order order) {
        // database logic
    }
}
 
class EmailService {
    void sendConfirmation(Order order) {
        // email logic
    }
}

Now each class has a much clearer responsibility:

Order

Business logic
 
OrderRepository

Database
 
EmailService

Email
 
InvoiceService

Invoice

Why is this useful in real projects? Imagine you are working on a Spring Boot project. Each layer has a focused responsibility:

  • Controller: HTTP/request handling
  • Service: Business logic
  • Repository: Data access

One class = one cohesive responsibility = one major reason to change.

Open / Closed Principle (OCP)

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

You should be able to add new behavior without changing existing, tested code.

Bad Example

class PaymentService {
 
    void pay(String type) {
 
        if (type.equals("UPI")) {
            System.out.println("Pay using UPI");
        }
        else if (type.equals("CARD")) {
            System.out.println("Pay using Card");
        }
        else if (type.equals("PAYPAL")) {
            System.out.println("Pay using PayPal");
        }
    }
}

Good Example: Applying OCP

Create an abstraction:

interface Payment {
    void pay();
}

Then create implementations:

class UPI implements Payment {
    public void pay() {
        System.out.println("Pay using UPI");
    }
}
class Card implements Payment {
    public void pay() {
        System.out.println("Pay using Card");
    }
}
class PayPal implements Payment {
    public void pay() {
        System.out.println("Pay using PayPal");
    }
}

Now the service doesn’t care which payment method is being used:

class PaymentService {
 
    void processPayment(Payment payment) {
        payment.pay();
    }
}

Now suppose you want Apple Pay. You don’t modify PaymentService. You simply extend the system:

class ApplePay implements Payment {
 
    public void pay() {
        System.out.println("Pay using Apple Pay");
    }
}

That’s OCP.

                    Payment

          ┌────────────┼────────────┐
          ↓            ↓            ↓
         UPI          Card        PayPal

                                  ApplePay

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering program correctness.

In simpler words:

If B is a subtype of A, you should be able to use B wherever A is expected, and the program should still work correctly.

Practical example: Bad design

Consider a bird hierarchy:

class Bird {
    void fly() {
        System.out.println("Flying");
    }
}

Now:

class Sparrow extends Bird {
    // Sparrow can fly
}

But:

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins can't fly");
    }
}

Now suppose we have:

void makeBirdFly(Bird bird) {
    bird.fly();
}

This works:

makeBirdFly(new Sparrow());

But this breaks:

makeBirdFly(new Penguin());

The problem is that Penguin claims to be a Bird, but it cannot fulfill the behavior expected from Bird. Therefore, the subtype violates LSP.

Better design

Don’t put fly() in the common Bird class.

class Bird {
    void eat() {
        System.out.println("Eating");
    }
}

Then create a separate abstraction for flying:

interface Flyable {
    void fly();
}

Now:

class Sparrow extends Bird implements Flyable {
 
    public void fly() {
        System.out.println("Sparrow flying");
    }
}

And:

class Penguin extends Bird {
    // Doesn't implement Flyable
}

Now the types correctly represent their capabilities.

             Bird
            /    \
       Sparrow   Penguin
          |
       Flyable

Another very practical example

Imagine:

class Rectangle {
    protected int width;
    protected int height;
 
    void setWidth(int width) {
        this.width = width;
    }
 
    void setHeight(int height) {
        this.height = height;
    }
 
    int getArea() {
        return width * height;
    }
}

You might think:

class Square extends Rectangle {
    @Override
    void setWidth(int width) {
        this.width = width;
        this.height = width;
    }
 
    @Override
    void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

Mathematically, a square is a rectangle. But from a software-behavior perspective, substituting Square for Rectangle can break assumptions. For example:

void resize(Rectangle r) {
    r.setWidth(10);
    r.setHeight(20);
 
    System.out.println(r.getArea());
}

For a normal rectangle: 10 × 20 = 200 For the Square implementation: 20 × 20 = 400

So the subtype changes the expected behavior of the base type. That’s an LSP violation.

The key idea

LSP isn’t really about inheritance syntax. It’s about behavior. If you write:

Animal animal = new Dog();

then Dog should behave in a way that makes sense wherever the program expects an Animal.

Bad:

Animal animal = new Dog();
animal.makeSound();  // expected to work

but Dog throws an exception or behaves completely differently from what Animal promises.

Interview definition:

LSP means that a subclass should be usable anywhere its superclass is expected without causing unexpected behavior, violating the superclass’s contract, or breaking program correctness.

Interface Segregation Principle (ISP)

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

The easiest way to understand ISP is:

Don’t create one huge interface. Split it into smaller interfaces based on what different classes actually need.

Practical example

Suppose you have:

interface Machine {
    void print();
    void scan();
    void fax();
}

Now you create a simple printer:

class SimplePrinter implements Machine {
 
    public void print() {
        System.out.println("Printing");
    }
 
    public void scan() {
        // I don't scan!
    }
 
    public void fax() {
        // I don't fax!
    }
}

The SimplePrinter is forced to implement scan() and fax(), even though it doesn’t need them. That’s an ISP violation.

Fix: split the interface

interface Printer {
    void print();
}
 
interface Scanner {
    void scan();
}
 
interface Fax {
    void fax();
}

Now:

class SimplePrinter implements Printer {
    public void print() {
        System.out.println("Printing");
    }
}

A multifunction machine can implement all three:

class MultiFunctionPrinter implements Printer, Scanner, Fax {
    public void print() {}
    public void scan() {}
    public void fax() {}
}

Now nobody is forced to implement something irrelevant.

Dependency Inversion Principle (DIP)

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

The simple idea is:

Your important business logic should not be tightly coupled to specific implementation details.

First, understand the problem

Suppose you build an application that sends notifications. You write:

class EmailService {
    void sendEmail(String message) {
        System.out.println("Sending email");
    }
}

And your business logic directly uses it:

class OrderService {
 
    private EmailService emailService = new EmailService();
 
    void placeOrder() {
        // order logic
        emailService.sendEmail("Order placed");
    }
}

The dependency is:

OrderService

EmailService

OrderService is high-level business logic. EmailService is a low-level implementation detail.

What’s the problem? Tomorrow you want to use SMS or WhatsApp. You have to modify OrderService. Your important business logic is tightly coupled to specific technologies.

Apply DIP

Create an abstraction:

interface NotificationService {
    void send(String message);
}

Now implementations depend on that abstraction:

class EmailService implements NotificationService {
    public void send(String message) {
        System.out.println("Sending Email");
    }
}
 
class SMSService implements NotificationService {
    public void send(String message) {
        System.out.println("Sending SMS");
    }
}

Now OrderService also depends on the interface, not Email specifically:

class OrderService {
 
    private NotificationService notificationService;
 
    OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }
 
    void placeOrder() {
        // business logic
        notificationService.send("Order placed");
    }
}

Now the dependency looks like:

        HIGH LEVEL
       OrderService

       NotificationService
          (interface)

        LOW LEVEL
    EmailService / SMSService

Both sides depend on the abstraction.

All SOLID together

You can now understand the five principles as problems they prevent:

S — SRP
"Why does this class have so many responsibilities?"
 
O — OCP
"Why must I modify existing code every time I add something?"
 
L — LSP
"Why can't this child properly behave like its parent?"
 
I — ISP
"Why am I forced to implement methods I don't need?"
 
D — DIP
"Why is my important business logic directly tied to this specific implementation?"

Memory trick

PrincipleThink
SRPOne class → focused responsibility
OCPAdd behavior without modifying existing code
LSPChild → must honor parent’s contract
ISPInterface → don’t force unnecessary methods
DIPDepend on abstractions, not concrete implementations