low-level-design7 min read

SOLID Design Principles Tutorial from Scratch (2026)

SOLID Design Principles Tutorial from Scratch (2026)

Published:  |  Category: Low Level Design  |  Reading time: ~15 min
SOLID Design Principles Tutorial from Scratch (2026)

In two decades of writing and reviewing production code across Java, C++, and C# ecosystems, I have found that the SOLID principles are the single most practical set of design guidelines for building maintainable object-oriented software. These five principles, introduced by Robert C. Martin, provide a clear framework for evaluating and improving class-level design decisions.

This tutorial explains each of the five SOLID principles — Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — with concrete code examples in Java and C++. You will learn not only what each principle means but also how to apply it in real-world scenarios.

S — Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. This means a class should be responsible for a single part of the functionality provided by the software, and that responsibility should be entirely encapsulated within the class. When a class has multiple responsibilities, changes to one responsibility may affect the others, making the system fragile and hard to maintain.

A useful heuristic: describe what your class does in a single sentence. If you use the word "and" or "or", it likely has more than one responsibility. For example, a class that "handles both data persistence and email notification" violates SRP and should be split into two classes.

// Violates SRP: class has multiple responsibilities
public class Invoice {
    public void calculateTotal() { /* ... */ }
    public void saveToDatabase() { /* ... */ }
    public void sendEmailInvoice() { /* ... */ }
}

// Follows SRP: each class has a single responsibility
public class InvoiceCalculator {
    public double calculateTotal(Invoice invoice) { return 0.0; }
}

public class InvoiceRepository {
    public void save(Invoice invoice) { /* ... */ }
}

public class EmailService {
    public void sendInvoice(Invoice invoice) { /* ... */ }
}

O — Open-Closed Principle (OCP)

The Open-Closed Principle states that classes should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing, tested code. The typical way to achieve this is through polymorphism — define an interface or abstract base class, then implement new behavior in new derived classes rather than modifying existing ones.

OCP is the driving force behind many design patterns, including Strategy, Template Method, Decorator, and Observer. By programming to an interface rather than a concrete implementation, you create a system that can grow without constantly rewriting existing code.

// Open for extension: new shapes can be added without modifying AreaCalculator
public interface Shape {
    double calculateArea();
}

public class Rectangle implements Shape {
    private double width, height;
    public Rectangle(double w, double h) { this.width = w; this.height = h; }
    public double calculateArea() { return width * height; }
}

public class Circle implements Shape {
    private double radius;
    public Circle(double r) { this.radius = r; }
    public double calculateArea() { return Math.PI * radius * radius; }
}

public class AreaCalculator {
    public double totalArea(List shapes) {
        return shapes.stream().mapToDouble(Shape::calculateArea).sum();
    }
}

L — Liskov Substitution Principle (LSP)

The Liskov Substitution Principle, introduced by Barbara Liskov, states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, a subclass should not override the behavior of the parent class in a way that violates the parent's contract. This is the most subtle and frequently violated SOLID principle.

A classic violation is the Rectangle-Square problem. A Square extends Rectangle but overrides setWidth to also set height, violating the parent's contract that setWidth only changes width. LSP violations lead to brittle code where developers must know the actual runtime type to use objects correctly, defeating the purpose of polymorphism.

// LSP violation: Square changes the behavior of Rectangle
public class Rectangle {
    protected int width, height;
    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        super.setWidth(w);
        super.setHeight(w);  // side effect violates parent contract
    }
}

// LSP-compliant: use a common interface instead
public interface Shape {
    int getArea();
}

public class LspRectangle implements Shape {
    protected int w, h;
    public LspRectangle(int w, int h) { this.w = w; this.h = h; }
    public int getArea() { return w * h; }
}

public class LspSquare implements Shape {
    protected int side;
    public LspSquare(int s) { this.side = s; }
    public int getArea() { return side * side; }
}

I — Interface Segregation Principle (ISP)

The Interface Segregation Principle states that no client should be forced to depend on methods it does not use. Larger interfaces should be split into smaller, more specific ones so that clients only need to know about the methods that are of interest to them. This reduces the impact of changes and makes the system more modular.

ISP is closely related to SRP but at the interface level rather than the class level. While SRP says a class should have one reason to change, ISP says an interface should represent one coherent set of behaviors. In practice, following ISP often leads to interfaces with a single method (functional interfaces), which naturally supports the Strategy pattern and lambda expressions.

// ISP violation: one fat interface
public interface MultiFunctionPrinter {
    void print(String doc);
    void scan(String doc);
    void fax(String doc);
    void staple(String doc);
}

// ISP compliance: segregated interfaces
public interface Printer { void print(String doc); }
public interface Scanner { void scan(String doc); }
public interface FaxMachine { void fax(String doc); }
public interface Stapler { void staple(String doc); }

// Each device implements only what it needs
public class SimplePrinter implements Printer {
    public void print(String doc) { System.out.println("Printing: " + doc); }
}

public class AllInOnePrinter implements Printer, Scanner, FaxMachine {
    public void print(String doc) { System.out.println("Printing: " + doc); }
    public void scan(String doc) { System.out.println("Scanning: " + doc); }
    public void fax(String doc) { System.out.println("Faxing: " + doc); }
}

D — Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions. This inverts the traditional dependency direction, making the system more flexible and testable.

The most common implementation of DIP is dependency injection — instead of a class creating its own dependencies (tight coupling), dependencies are passed in through the constructor or setter methods. This allows you to easily swap implementations, use mocks in tests, and configure the system from a central location (often called a composition root or dependency injection container).

// DIP violation: high-level class depends on low-level concrete class
public class LightBulb {
    public void turnOn() { System.out.println("LightBulb on"); }
    public void turnOff() { System.out.println("LightBulb off"); }
}

public class Switch {
    private LightBulb bulb = new LightBulb();  // depends on concrete class
    public void operate() { bulb.turnOn(); }
}

// DIP compliance: both depend on abstraction
public interface Switchable {
    void turnOn();
    void turnOff();
}

public class DipLightBulb implements Switchable {
    public void turnOn() { System.out.println("LightBulb on"); }
    public void turnOff() { System.out.println("LightBulb off"); }
}

public class DipSwitch {
    private Switchable device;
    public DipSwitch(Switchable device) { this.device = device; }
    public void operate() { device.turnOn(); }
}

Frequently Asked Questions

What does SOLID stand for?

SOLID is an acronym: S (Single Responsibility Principle), O (Open-Closed Principle), L (Liskov Substitution Principle), I (Interface Segregation Principle), D (Dependency Inversion Principle).

Which SOLID principle is most commonly violated?

The Single Responsibility Principle (SRP) is the most commonly violated because it is easy to gradually add more responsibilities to an existing class over time. The Liskov Substitution Principle (LSP) is the most subtly violated.

Do SOLID principles apply to functional programming?

Some principles like SRP and OCP apply universally. Others like LSP are less relevant in functional languages that avoid class hierarchies. Dependency Inversion is still relevant — it translates to depending on function signatures rather than concrete implementations.

Can SOLID principles be applied to microservices?

Yes, at a higher level of abstraction. SRP means each microservice should have one business capability. OCP means services should be extendable via new services rather than modifying existing ones. DIP means services should communicate through contracts (APIs) rather than direct dependencies.

Originally published on Ayodhyyya. Last updated June 1, 2026.