The Chronicle 6 min read
Object-Oriented Design

SOLID Principles Explained with Real-World Examples

The five principles every software engineer needs to master for clean, maintainable code

L
LLDCanvas Team·Engineering
July 24, 2026
6 min read 75 views 0 likes
Jump to section

Robert C. Martin ("Uncle Bob") coined the SOLID acronym in 2000 to describe five object-oriented design principles that had already been circulating in the industry for years. More than two decades later, SOLID is still the most common vocabulary interviewers reach for when they ask you to design or critique a class hierarchy, because the principles capture recurring failure modes rather than abstract theory.

Each principle answers a version of the same question: when requirements change, how much of my code has to change with them? Code that violates SOLID tends to work fine on day one and become brittle by month three - new features require touching classes that have nothing to do with the feature, and a single bug fix ripples across the codebase. Learning to spot these violations, and to fix them with the right pattern, is one of the highest-leverage skills for both interviews and production work.

PrincipleOne-line summary
Single Responsibility (SRP)A class should have only one reason to change.
Open/Closed (OCP)Software entities should be open for extension but closed for modification.
Liskov Substitution (LSP)Subtypes must be substitutable for their base types without breaking correctness.
Interface Segregation (ISP)Clients should not be forced to depend on methods they do not use.
Dependency Inversion (DIP)Depend on abstractions, not on concrete implementations.

Want to see SOLID applied inside full design problems, not just toy examples? The revision notes on LLDCanvas walk through all five principles with interactive, annotated code.


01S - Single Responsibility Principle (SRP)

Definition: a class should have only one reason to change. In practice this means a class should own exactly one job - one axis along which requirements can evolve - and delegate everything else. SRP is the principle interviewers probe most often because "God classes" that mix business logic, persistence, and presentation are extremely common in real codebases.

java
// BAD: Employee has three separate reasons to change -
// a payroll rule change, a database schema change, or a report format change.
public class Employee {
    public double calculatePay() {
        // business/payroll logic
        return baseSalary * 1.1;
    }

    public void save() {
        // JDBC / SQL persistence logic
        String sql = "INSERT INTO employees VALUES (...)";
        // execute(sql);
    }

    public String generateReport() {
        // report formatting logic
        return "Employee Report: " + this.toString();
    }
}

Notice that a change to how paychecks are calculated, a change to the database vendor, and a change to report formatting all land in the same file. Three unrelated teams could end up editing Employee in the same sprint, and a typo in the reporting method can break something that has nothing to do with reports.

java
// GOOD: each class has exactly one responsibility.
public class Employee {
    private double baseSalary;
    public double calculatePay() {
        return baseSalary * 1.1;
    }
}

public class EmployeeRepository {
    public void save(Employee e) {
        // JDBC / SQL persistence logic lives here only
    }
}

public class EmployeeReportGenerator {
    public String generate(Employee e) {
        return "Employee Report: " + e;
    }
}

The payoff is isolation: a schema migration only touches EmployeeRepository, a new payslip format only touches EmployeeReportGenerator, and none of that logic needs to be re-tested when the other two change. SRP is also what makes unit testing tractable - a class with one responsibility needs far fewer mocks and far fewer test permutations.


02O - Open/Closed Principle (OCP)

Definition: software entities (classes, modules, functions) should be open for extension but closed for modification. In other words, adding a new behavior should mean adding new code, not editing code that already works and is already tested.

java
// BAD: every new customer tier means editing this method again.
public class DiscountCalculator {
    public double calculate(Order order) {
        if (order.getType() == CustomerType.REGULAR) {
            return order.getTotal() * 0.05;
        } else if (order.getType() == CustomerType.PREMIUM) {
            return order.getTotal() * 0.10;
        }
        // A new tier means: modify this method, redeploy, retest everything.
        return 0;
    }
}

This if/else chain grows forever, and every edit risks breaking a discount rule that used to work. It also violates SRP in a subtle way - DiscountCalculator now has to know about every customer type that will ever exist.

java
// GOOD: Strategy pattern - new tiers are new classes, DiscountCalculator never changes.
public interface DiscountStrategy {
    double calculate(Order order);
}

public class RegularDiscount implements DiscountStrategy {
    public double calculate(Order order) { return order.getTotal() * 0.05; }
}

public class PremiumDiscount implements DiscountStrategy {
    public double calculate(Order order) { return order.getTotal() * 0.10; }
}

public class DiscountCalculator {
    private final DiscountStrategy strategy;
    public DiscountCalculator(DiscountStrategy strategy) { this.strategy = strategy; }
    public double calculate(Order order) { return strategy.calculate(order); }
}

Adding a LoyaltyDiscount tier now means writing one new class and wiring it in - DiscountCalculator itself is never touched again, so it never needs to be re-reviewed or re-tested for regressions. This is the principle underneath most of the Strategy, Decorator, and Factory patterns you'll be asked about in LLD interviews.


03L - Liskov Substitution Principle (LSP)

Definition: objects of a subclass must be substitutable for objects of the superclass without altering the correctness of the program. If code that works with a Shape breaks when handed a specific subclass, that subclass has violated its parent's contract - inheritance is being used for code reuse instead of for a genuine "is-a" relationship.

java
// BAD: Square "is-a" Rectangle in geometry, but not in code -
// overriding setWidth/setHeight breaks the Rectangle contract.
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)  { width = height = w; }  // silently changes height too!
    @Override
    public void setHeight(int h) { width = height = h; }  // silently changes width too!
}

// Any code written and tested against Rectangle now breaks:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50; // FAILS - actually returns 100

The bug isn't in Square or Rectangle individually - it's that Square cannot honor every promise Rectangle makes (independent width/height mutation), yet the type system claims it can. Any caller that trusted the Rectangle contract now gets silently wrong answers.

java
// GOOD: no inheritance relationship where none truly exists.
// Both shapes implement a shared, narrower contract instead.
public interface Shape {
    int getArea();
}

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

public class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }
    public int getArea() { return side * side; }
}

Making both shapes immutable and independent removes the shared mutable state that caused the contract violation in the first place. The general lesson generalizes well beyond geometry: before extending a class, ask whether every method the base class exposes still makes sense - and behaves identically - on the subclass. If not, favor composition or a shared interface over inheritance.


04I - Interface Segregation Principle (ISP)

Definition: clients should not be forced to depend on methods they do not use. Wide, "fat" interfaces force every implementer to either support behavior that makes no sense for it, or throw exceptions from stub methods - both are red flags in a design review.

java
// BAD: a single fat interface forces every worker to implement everything.
public interface WorkerInterface {
    void work();
    void eat();
    void sleep();
}

public class Robot implements WorkerInterface {
    public void work()  { /* does actual work */ }
    public void eat()   { throw new UnsupportedOperationException(); }
    public void sleep() { throw new UnsupportedOperationException(); }
}

Robot is forced to declare methods it can never meaningfully implement. Any code that iterates over WorkerInterface and calls eat() polymorphically will now crash the moment a Robot is in the list - the interface made a promise on the class's behalf that the class cannot keep.

java
// GOOD: split into focused, single-purpose interfaces.
public interface Workable  { void work();  }
public interface Eatable   { void eat();   }
public interface Sleepable { void sleep(); }

public class Robot implements Workable {
    public void work() { /* does actual work */ }
}

public class HumanWorker implements Workable, Eatable, Sleepable {
    public void work()  { /* ... */ }
    public void eat()   { /* ... */ }
    public void sleep() { /* ... */ }
}

Now each class only implements the capabilities it genuinely has, and code that depends on Eatable can never accidentally be handed a Robot. Smaller interfaces also make mocking in unit tests trivial - you implement exactly the one method the test needs, nothing more.


05D - Dependency Inversion Principle (DIP)

Definition: high-level modules should not depend on low-level modules - both should depend on abstractions. DIP is what makes a codebase testable and swappable: business logic should never new up a concrete database, HTTP client, or file writer directly.

java
// BAD: OrderService is welded to one concrete database implementation.
public class OrderService {
    private MySQLOrderRepository repository = new MySQLOrderRepository();

    public void placeOrder(Order order) {
        repository.save(order);
    }
}

There is no way to unit test OrderService without a real MySQL connection, and no way to switch to Postgres, DynamoDB, or an in-memory store for tests without rewriting OrderService itself. The high-level policy ("place an order") is chained to a low-level detail ("MySQL").

java
// GOOD: OrderService depends on an abstraction, not a concrete class.
public interface OrderRepository {
    void save(Order order);
}

public class MySQLOrderRepository implements OrderRepository {
    public void save(Order order) { /* JDBC logic */ }
}

public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository; // injected, not constructed
    }

    public void placeOrder(Order order) {
        repository.save(order);
    }
}

OrderService now only knows about the OrderRepository contract. Tests can inject an in-memory fake, production can inject MySQLOrderRepository, and a future migration to a different database only requires a new class that implements the same interface - OrderService never changes. This is the principle behind dependency injection frameworks like Spring, and it is the reason "program to an interface, not an implementation" shows up in nearly every design pattern.

Curious how these five principles show up inside real interview problems, not isolated snippets? Browse LLDCanvas's practice problems to apply SOLID to full class designs.


06Conclusion

SOLID is not a checklist to recite - it's a diagnostic lens. When a class is hard to test, ask whether it's doing too much (SRP). When adding a feature means editing five existing files, ask whether the design should have been open for extension instead (OCP). When a subclass needs special-case handling from its callers, question the inheritance itself (LSP). When an implementation is full of stub methods, split the interface (ISP). And when a class can't be tested without a live dependency, invert that dependency (DIP).

In an interview, the strongest signal you can give isn't reciting definitions - it's noticing a violation in your own design mid-explanation and refactoring toward the right pattern out loud. That instinct only comes from having written both the violation and the fix enough times that the smell becomes automatic.

Frequently Asked Questions

ASOLID principles are the foundation of every design pattern question and every code review conversation.

L

LLDCanvas Team

Engineering at LLDCanvas

Ready to practice?

Turn reading into results

110+ LLD interview questions, a live canvas, and timed Interview Mode.

Reader’s Notes (0)

Sign in to join the discussion

Sign in

Loading notes…