SOLID Principles

SOLID is an acronym for five object-oriented design principles that, taken together, make software easier to understand, extend, and maintain without breaking existing behaviour. Each principle targets a different kind of design rot — rigidity, fragility, immobility, viscosity — that accumulates as codebases grow.

LetterPrincipleOne-line summary
SSingle ResponsibilityA class should have only one reason to change
OOpen / ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be fully substitutable for their base types
IInterface SegregationMany focused interfaces beat one fat general-purpose one
DDependency InversionDepend on abstractions, not concrete implementations

S — Single Responsibility Principle

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

A class that handles multiple concerns couples those concerns together. A change to the email format should never risk breaking the database logic, and vice versa. The fix is to split the class so that each unit owns exactly one responsibility.

Violation

// One class does three unrelated jobs — three reasons to change
public class UserService {

    public void saveUser(User user) {
        // 1. Validate
        if (user.getName() == null || user.getName().isBlank()) {
            throw new IllegalArgumentException("Name is required");
        }
        // 2. Persist
        String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
        // ... execute SQL ...

        // 3. Notify
        String body = "Welcome, " + user.getName() + "!";
        // ... send email via SMTP ...
    }
}

Fixed

// Each class has one reason to change
public class UserValidator {
    public void validate(User user) {
        if (user.getName() == null || user.getName().isBlank()) {
            throw new IllegalArgumentException("Name is required");
        }
    }
}

public class UserRepository {
    public void save(User user) {
        String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
        // ... execute SQL ...
    }
}

public class WelcomeEmailSender {
    public void send(User user) {
        String body = "Welcome, " + user.getName() + "!";
        // ... send email via SMTP ...
    }
}

// Orchestration lives in a thin coordinator — also a single job: coordinate
public class UserRegistrationService {
    private final UserValidator    validator;
    private final UserRepository   repository;
    private final WelcomeEmailSender emailSender;

    public UserRegistrationService(UserValidator v, UserRepository r, WelcomeEmailSender e) {
        this.validator   = v;
        this.repository  = r;
        this.emailSender = e;
    }

    public void register(User user) {
        validator.validate(user);
        repository.save(user);
        emailSender.send(user);
    }
}

Now changing the email template only touches WelcomeEmailSender. Swapping the database only touches UserRepository. The validator, the repository, and the email sender can all be tested independently.

O — Open / Closed Principle

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

Every time you open an existing, working class and add an if branch to accommodate a new case, you risk breaking the behaviour that was already there. The OCP asks you to design classes so that new behaviour is added by writing new code, not by changing old code.

Violation

// Adding a new discount type means touching this class — risky
public class DiscountCalculator {
    public double calculate(String discountType, double price) {
        if (discountType.equals("PERCENTAGE")) {
            return price * 0.9;
        } else if (discountType.equals("FIXED")) {
            return price - 10;
        } else if (discountType.equals("BOGO")) {   // new requirement → edit existing class
            return price / 2;
        }
        return price;
    }
}

Fixed

// Abstraction that will never need to change
public interface DiscountStrategy {
    double apply(double price);
}

// New discount types are added by writing new classes, not editing old ones
public class PercentageDiscount implements DiscountStrategy {
    private final double percent;
    public PercentageDiscount(double percent) { this.percent = percent; }

    @Override public double apply(double price) { return price * (1 - percent / 100); }
}

public class FixedDiscount implements DiscountStrategy {
    private final double amount;
    public FixedDiscount(double amount) { this.amount = amount; }

    @Override public double apply(double price) { return price - amount; }
}

public class BogoDiscount implements DiscountStrategy {
    @Override public double apply(double price) { return price / 2; }
}

// Calculator is closed for modification — it never changes
public class DiscountCalculator {
    private final DiscountStrategy strategy;

    public DiscountCalculator(DiscountStrategy strategy) {
        this.strategy = strategy;
    }

    public double calculate(double price) {
        return strategy.apply(price);
    }
}

// Usage
DiscountCalculator calc = new DiscountCalculator(new PercentageDiscount(10));
System.out.println(calc.calculate(100.0)); // 90.0

The OCP is most naturally achieved through the Strategy, Template Method, and Decorator patterns — all of which use abstraction to make the stable core indifferent to new variants.

L — Liskov Substitution Principle

"Objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program."

LSP tightens the informal IS-A relationship. It is not enough for a Square to extend a Rectangle in a type hierarchy — the subtype must honour the behavioural contract of the parent. If substituting a subtype causes callers to break or produce wrong results, the hierarchy is flawed.

Classic violation — the Square / Rectangle problem

public class Rectangle {
    protected int width;
    protected int height;

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

// Geometrically a square IS-A rectangle — but behaviourally it breaks the contract
public class Square extends Rectangle {
    @Override
    public void setWidth(int side) {
        this.width  = side;
        this.height = side; // must keep sides equal
    }

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

// Code that works correctly with Rectangle silently breaks with Square
public static void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    // Caller expects area = 50
    System.out.println(r.area()); // Rectangle → 50 ✓  |  Square → 100 ✗
}

Fixed — break the flawed hierarchy

// Shape defines only what both truly share
public interface Shape {
    int area();
}

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

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

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

public class Square implements Shape {
    private final int side;

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

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

// Both are substitutable for Shape — the contract is honoured by both
public static void printArea(Shape s) {
    System.out.println("Area: " + s.area());
}

printArea(new Rectangle(5, 10)); // Area: 50
printArea(new Square(5));        // Area: 25

LSP violations are a signal that the inheritance hierarchy does not match the behavioural contracts of the types involved. The fix is usually to flatten the hierarchy or introduce a more appropriate shared abstraction. Rules for LSP-safe subtypes:

  1. Do not strengthen preconditions — a subtype cannot require more from the caller than the parent does.
  2. Do not weaken postconditions — a subtype must deliver at least what the parent promises.
  3. Preserve invariants — any invariant that holds for the parent must still hold for the subtype.
  4. Do not throw new (unchecked) exceptions that callers of the parent type do not expect.

I — Interface Segregation Principle

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

A fat interface forces every implementor to provide stubs or throw UnsupportedOperationException for methods that are irrelevant to them. This is a hidden coupling: a change to a method that a class never uses can still force that class to recompile or change. The fix is to split the interface into smaller, role-specific contracts.

Violation

// One bloated interface — every implementor is forced to deal with every method
public interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
    void writeReport();
}

// Robot can work but it doesn't eat, sleep, or write reports
public class Robot implements Worker {
    @Override public void work()          { System.out.println("Robot working"); }
    @Override public void eat()           { throw new UnsupportedOperationException(); }
    @Override public void sleep()         { throw new UnsupportedOperationException(); }
    @Override public void attendMeeting() { throw new UnsupportedOperationException(); }
    @Override public void writeReport()   { throw new UnsupportedOperationException(); }
}

Fixed

// Small, focused interfaces — implementors only take what they need
public interface Workable    { void work(); }
public interface Feedable    { void eat();  void sleep(); }
public interface Reportable  { void attendMeeting(); void writeReport(); }

// Human needs all three roles
public class HumanWorker implements Workable, Feedable, Reportable {
    @Override public void work()          { System.out.println("Human working"); }
    @Override public void eat()           { System.out.println("Human eating");  }
    @Override public void sleep()         { System.out.println("Human sleeping"); }
    @Override public void attendMeeting() { System.out.println("In meeting");    }
    @Override public void writeReport()   { System.out.println("Writing report"); }
}

// Robot only needs Workable — no stubs, no exceptions
public class Robot implements Workable {
    @Override public void work() { System.out.println("Robot working"); }
}

// Code that orchestrates work only depends on Workable — doesn't know about eating
public class WorkManager {
    private final List<Workable> workers;

    public WorkManager(List<Workable> workers) { this.workers = workers; }

    public void startWork() {
        workers.forEach(Workable::work);
    }
}

WorkManager manager = new WorkManager(List.of(new HumanWorker(), new Robot()));
manager.startWork();

A practical heuristic: if you find yourself writing throw new UnsupportedOperationException() inside an interface method, the interface is too broad and is violating ISP.

D — Dependency Inversion Principle

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

When a high-level class directly instantiates a low-level class, it is hard-wired to that concrete implementation. Swapping the database, changing the notification mechanism, or testing in isolation all become painful. DIP breaks this by inserting an interface between them — both sides depend on the contract, not on each other.

Violation

// High-level class hard-wired to a concrete low-level class
public class OrderService {
    private final MySQLOrderRepository repository = new MySQLOrderRepository(); // concrete!
    private final EmailNotifier        notifier   = new EmailNotifier();         // concrete!

    public void placeOrder(Order order) {
        repository.save(order);
        notifier.notify(order.getCustomerEmail(), "Order confirmed");
    }
}

// Swapping MySQL for Postgres, or email for SMS, means editing OrderService
// Testing OrderService in isolation is impossible without hitting a real DB and SMTP server

Fixed

// Abstractions — the contracts both sides depend on
public interface OrderRepository {
    void save(Order order);
}

public interface Notifier {
    void notify(String recipient, String message);
}

// Low-level modules implement the abstractions
public class MySQLOrderRepository implements OrderRepository {
    @Override public void save(Order order) { /* MySQL logic */ }
}

public class PostgresOrderRepository implements OrderRepository {
    @Override public void save(Order order) { /* Postgres logic */ }
}

public class EmailNotifier implements Notifier {
    @Override public void notify(String email, String message) { /* SMTP */ }
}

public class SmsNotifier implements Notifier {
    @Override public void notify(String phone, String message) { /* SMS gateway */ }
}

// High-level module depends only on abstractions — never on concrete classes
public class OrderService {
    private final OrderRepository repository;
    private final Notifier        notifier;

    // Dependencies are injected — the caller decides which implementation to use
    public OrderService(OrderRepository repository, Notifier notifier) {
        this.repository = repository;
        this.notifier   = notifier;
    }

    public void placeOrder(Order order) {
        repository.save(order);
        notifier.notify(order.getCustomerEmail(), "Order confirmed");
    }
}

// Production wiring
OrderService prodService = new OrderService(
    new MySQLOrderRepository(),
    new EmailNotifier()
);

// Test wiring — no database, no SMTP server needed
OrderService testService = new OrderService(
    new InMemoryOrderRepository(),  // test double
    new FakeNotifier()              // test double
);

This pattern is called Dependency Injection (DI) — the concrete implementations are provided (injected) from the outside rather than created inside. Frameworks like Spring handle the wiring automatically, but the principle applies equally to manual construction.

DIP is the principle that makes the other four actionable at scale. SRP gives you small classes; OCP makes them extensible; LSP keeps hierarchies honest; ISP keeps contracts lean. DIP then lets all of these pieces be composed and swapped freely without coupling the high-level logic to low-level details.