$ emrebener
home topics software development solid principles explained simply

SOLID Principles Explained Simply

1. What SOLID is

SOLID is a set of five object-oriented design principles that aim to keep code easier to maintain as it grows.

Robert C. Martin formulated the principles across a series of articles and papers in the late 1990s, most notably consolidated in his 2000 paper Design Principles and Design Patterns. Then Michael Feathers rearranged Martin’s five principles into a memorable order and coined the SOLID acronym in 2004.

Robert C. Martin (Uncle Bob) revisited the principles later in Clean Architecture (2017).

Each letter names one principle. Together they push designs toward small classes, stable interfaces, and dependencies that point the right way.

  • S — Single Responsibility Principle
  • O — Open/Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

None of them are absolute laws, but they are generally considered best practices.

2. S — Single Responsibility Principle

A class should have one reason to change. If two unrelated concerns can each force you to edit the same class, the class is doing too much.

That definition is more specific than “a class should only have one responsibility” on purpose, because “responsibility” is vague on its own.

Clean Architecture (2017) defines the principle this way:

A module should be responsible to one, and only one, actor. (An “actor” is a group of users or stakeholders who want the system to change in the same way.)

Take an Invoice that calculates its own total, formats itself as HTML, and persists itself:

public class Invoice {
    private final List<LineItem> items;

    public BigDecimal total() {
        return items.stream()
            .map(LineItem::amount)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public String toHtml() {
        // formatting logic
    }

    public void save(Connection db) {
        // SQL logic
    }
}

Three concerns share one class: a finance rule change rewrites total(), a design tweak rewrites toHtml(), a database migration rewrites save(). Split them:

public class Invoice {
    private final List<LineItem> items;

    public BigDecimal total() {
        return items.stream()
            .map(LineItem::amount)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

public class InvoiceHtmlRenderer {
    public String render(Invoice invoice) {
        // formatting logic
    }
}

public class InvoiceRepository {
    public void save(Invoice invoice, Connection db) {
        // SQL logic
    }
}

Now each class changes for one reason. The opposite failure mode is just as real. Classes that hold nothing and only forward calls are a sign you split too aggressively, and SRP is about clustering things that change together rather than minimizing methods per class.

Before — one class, three actorsAfter — one class per actorFinanceDesignDB OpsInvoicetotal()toHtml()save()FinanceDesignDB OpsInvoice · total()InvoiceHtmlRenderer · render()InvoiceRepository · save()Before — one class, three actorsAfter — one class per actorFinanceDesignDB OpsInvoicetotal()toHtml()save()FinanceDesignDB OpsInvoice · total()InvoiceHtmlRenderer · render()InvoiceRepository · save()

3. O — Open/Closed Principle

A software artifact should be open for extension but closed for modification.

Adding a new variant should not require editing the code that already handles the existing variants.

A type-switching area calculator violates this:

public class AreaCalculator {
    public double area(Object shape) {
        if (shape instanceof Circle c) {
            return Math.PI * c.radius() * c.radius();
        } else if (shape instanceof Square s) {
            return s.side() * s.side();
        }
        throw new IllegalArgumentException("Unknown shape");
    }
}

Every new shape forces an edit to the area method, which violates the open/closed principle.

Push the variation behind a polymorphic seam:

public interface Shape {
    double area();
}

public record Circle(double radius) implements Shape {
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public record Square(double side) implements Shape {
    @Override
    public double area() {
        return side * side;
    }
}

Each shape now calculates its own area by overriding area. The system is open for extension and existing code stays untouched when a new shape shows up. That’s the general idea.

The trap is extrapolating this into “every class needs an interface up front.” You only pay the cost where you actually expect new variants, and premature seams are as expensive as missing ones.

4. L — Liskov Substitution Principle

A subtype must be usable anywhere its supertype is expected, without surprising the caller.

Clean Architecture (2017) argues that to build software systems out of interchangeable parts, the parts have to honor a contract that lets one stand in for another.

If a function works with T, it must keep working when handed any subclass of T.

The textbook violation (canonical example) models a Square as a Rectangle:

public 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;
    }
}

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

    @Override
    public void setHeight(int h) {
        this.width = h;
        this.height = h;
    }
}

A caller holding a Rectangle reference reasonably expects width and height to move independently:

void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20; // fails when r is actually a Square
}

The fix is to stop pretending this inheritance is real. A square is not a rectangle in any behavioral sense; model them as siblings instead:

public interface Shape {
    int area();
}

public record Rectangle(int width, int height) implements Shape {
    @Override
    public int area() {
        return width * height;
    }
}

public record Square(int side) implements Shape {
    @Override
    public int area() {
        return side * side;
    }
}

Broken — Square extends RectangleFixed — siblings under ShapeRectangleSquareextendssetWidth + setHeightcontract broken«interface» ShapeRectangleSquareimplementsimplementseach owns its own area()Broken — Square extends RectangleFixed — siblings under ShapeRectangleSquareextendssetWidth + setHeightcontract broken«interface» ShapeRectangleSquareimplementsimplementseach owns its own area()

Liskov substitution principle (LSP) helps catch inheritance hierarchies built on a superficial “is-a” reading.

If overriding a method changes the supertype’s contract — throwing where it didn’t, restricting inputs it used to accept, weakening guarantees on the output — you have an LSP violation, even if the compiler is happy.

5. I — Interface Segregation Principle

The dependency of one class to another one should depend on the smallest possible interface.

Clean Architecture (2017) puts it bluntly:

Do not force clients to be coupled to methods, modules, or baggage they don’t actually need.

A wide interface that lumps unrelated capabilities pushes empty or throwing implementations onto types that only need a slice.

A MultiFunctionDevice interface bundles every operation a device might do:

public interface MultiFunctionDevice {
    void print(Document d);
    void scan(Document d);
    void fax(Document d);
}

A simple printer is forced to implement methods it has no answer for:

public class SimplePrinter implements MultiFunctionDevice {
    @Override
    public void print(Document d) {
        // print logic
    }

    @Override
    public void scan(Document d) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void fax(Document d) {
        throw new UnsupportedOperationException();
    }
}

The throws are an LSP violation in slow motion; any code that holds a MultiFunctionDevice and calls scan will explode the moment it gets a SimplePrinter. Split the interface along the lines callers actually depend on:

public interface Printer {
    void print(Document d);
}

public interface DocumentScanner {
    void scan(Document d);
}

public interface FaxMachine {
    void fax(Document d);
}

public class SimplePrinter implements Printer {
    @Override
    public void print(Document d) {
        // print logic
    }
}

public class OfficeMachine implements Printer, DocumentScanner, FaxMachine {
    // implements all three
}

Before — fat interface, throwing methodsAfter — interfaces split along use«interface» MultiFunctionDeviceprint()scan()fax()SimplePrinter────✓ print()✗ scan() throws✗ fax() throwsimplements«interface»Printer«interface»DocumentScanner«interface»FaxMachineSimplePrinterprint()OfficeMachineprint() · scan() · fax()Before — fat interface, throwing methodsAfter — interfaces split along use«interface» MultiFunctionDeviceprint()scan()fax()SimplePrinter────✓ print()✗ scan() throws✗ fax() throwsimplements«interface»Printer«interface»DocumentScanner«interface»FaxMachineSimplePrinterprint()OfficeMachineprint() · scan() · fax()

Now SimplePrinter only carries the contract it can honor, and a function that only needs to print can accept a Printer parameter without dragging scan and fax along. ISP is the interface-side mirror of SRP. Keep capability boundaries narrow so consumers depend on the smallest surface that does the job.

6. D — Dependency Inversion Principle

High-level policy should not depend on low-level details. Both should depend on abstractions, and the abstraction belongs to the policy, not the detail.

Clean Architecture (2017) more clearly defines the principle as:

Source code dependencies should refer only to abstractions, not to concretions.

A notification service that constructs its own SMTP client hardcodes a low-level choice into a high-level workflow:

public class NotificationService {
    private final SmtpEmailSender sender = new SmtpEmailSender();

    public void notify(User user, String message) {
        sender.send(user.email(), message);
    }
}

Switching transport, sending an SMS instead, or testing without hitting the network all require editing NotificationService itself. Invert the direction: define the abstraction the policy needs, then inject any implementation that satisfies it:

public interface MessageSender {
    void send(User user, String message);
}

public class NotificationService {
    private final MessageSender sender;

    public NotificationService(MessageSender sender) {
        this.sender = sender;
    }

    public void notify(User user, String message) {
        sender.send(user, message);
    }
}

public class SmtpEmailSender implements MessageSender {
    @Override
    public void send(User user, String message) {
        // SMTP logic
    }
}

Before — policy reachesdown to detailAfter — both depend onthe abstractionNotificationService(high-level)SmtpEmailSender(low-level)new SmtpEmailSender()NotificationService(high-level)«interface» MessageSenderSmtpEmailSender(low-level)depends onimplementsowned by policyBefore — policy reachesdown to detailAfter — both depend onthe abstractionNotificationService(high-level)SmtpEmailSender(low-level)new SmtpEmailSender()NotificationService(high-level)«interface» MessageSenderSmtpEmailSender(low-level)depends onimplementsowned by policy

NotificationService no longer cares whether the sender is SMTP, SMS or a test fake recording calls in memory. The “inversion” is in the direction of the dependency: instead of the high-level service reaching down for a concrete class, the concrete class implements an abstraction shaped by the policy that uses it.

DIP is what makes the rest of the letters pay off at runtime. OCP gives you a place to swap behavior; DIP is how you actually swap it. ISP defines the narrow interfaces that DIP then depends on.