PrepZone Logo
PrepZone

SOLID in Practice

Five principles as five questions to ask about a class, with the refactor each one suggests.

Why this matters

  • These five names come up in every design discussion and most interviews, and reciting the acronym without an example is transparently shallow.
  • Each principle has a concrete code smell attached, which makes them usable while you type rather than only during review.
  • Applied mechanically they produce over-engineered code, so knowing when not to apply one is part of knowing them.

The five at a glance

Single responsibilityOne reason to change
Open / closedExtend, do not edit
Liskov substitutionSubtype keeps the promise
Interface segregationSmall, focused contracts
Dependency inversionDepend on abstractions
ResultSwappable, testable units
Read them as questions about a single class: does it have one job, can I extend it without editing it, and does it depend on contracts rather than concrete types?

Single Responsibility

The question: if this class had to change, how many different reasons could cause it?

A class should have one reason to change — one group of stakeholders whose decisions affect it.

Java
// Three reasons to change: report format, tax rules, email delivery
public class InvoiceService {
    public void process(Order order) {
        BigDecimal tax = order.total().multiply(new BigDecimal("0.18"));
        String html = "<h1>Invoice</h1>" + order.id();
        sendEmail(order.customerEmail(), html);
    }
    private void sendEmail(String to, String body) { }
}
Java
// One reason each
public class TaxCalculator {
    public BigDecimal taxFor(Order order) { }
}

public class InvoiceRenderer {
    public String render(Order order, BigDecimal tax) { }
}

public class EmailSender {
    public void send(String to, String body) { }
}

public class InvoiceService {              // coordinates, decides nothing itself
    private final TaxCalculator tax;
    private final InvoiceRenderer renderer;
    private final EmailSender mailer;

    public InvoiceService(TaxCalculator tax, InvoiceRenderer renderer, EmailSender mailer) {
        this.tax = tax;
        this.renderer = renderer;
        this.mailer = mailer;
    }

    public void process(Order order) {
        BigDecimal amount = tax.taxFor(order);
        mailer.send(order.customerEmail(), renderer.render(order, amount));
    }
}

Open/Closed

The question: to add a new case, do I have to edit existing code or only add new code?

Open for extension, closed for modification. The smell is a growing if/else or switch on a type.

Java
// Every new shipping method edits this method
public class ShippingCalculator {
    public BigDecimal cost(String method, BigDecimal weight) {
        if (method.equals("STANDARD")) return weight.multiply(new BigDecimal("2"));
        if (method.equals("EXPRESS"))  return weight.multiply(new BigDecimal("5"));
        throw new IllegalArgumentException(method);
    }
}
Java
// Every new method is a new class; nothing existing is touched
public interface ShippingMethod {
    BigDecimal cost(BigDecimal weight);
}

public class Standard implements ShippingMethod {
    public BigDecimal cost(BigDecimal weight) { return weight.multiply(new BigDecimal("2")); }
}

public class Overnight implements ShippingMethod {       // added later, nothing else changed
    public BigDecimal cost(BigDecimal weight) { return weight.multiply(new BigDecimal("12")); }
}

Liskov Substitution

The question: can I pass a subclass anywhere the parent is expected without anything breaking?

A subtype must honour the parent's promises. The smell is an override that throws, returns something the contract forbids, or demands more of its caller.

Java
public class Rectangle {
    protected int width, height;
    public void setWidth(int width)   { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int area() { return width * height; }
}

public class Square extends Rectangle {
    @Override public void setWidth(int width)   { this.width = width;  this.height = width; }
    @Override public void setHeight(int height) { this.width = height; this.height = height; }
}
Java
void resize(Rectangle rectangle) {
    rectangle.setWidth(5);
    rectangle.setHeight(4);
    assert rectangle.area() == 20;      // holds for Rectangle, fails for Square (16)
}

A Square is a rectangle in geometry but not a valid substitute for this mutable Rectangle, because it silently violates the independence of width and height. The fix is to drop the inheritance — make both immutable implementations of a Shape interface, where no such assumption exists.

Interface Segregation

The question: does every implementer need every method on this interface?

The smell is an implementation full of methods that throw UnsupportedOperationException.

Java
// A read-only data source is forced to implement writes
public interface DataStore {
    String read(String key);
    void write(String key, String value);
    void delete(String key);
    void beginTransaction();
    void commit();
}
Java
// Small interfaces, composed where a type genuinely supports more
public interface ReadableStore {
    String read(String key);
}

public interface WritableStore {
    void write(String key, String value);
    void delete(String key);
}

public interface TransactionalStore extends ReadableStore, WritableStore {
    void beginTransaction();
    void commit();
}

A cache implements ReadableStore honestly. A database implements TransactionalStore. Nobody throws to satisfy a signature they cannot honour.

Dependency Inversion

The question: does my business logic name a concrete class it could do without?

High-level code should depend on abstractions, not on details — and the abstraction should be owned by the high-level code, which is where the word "inversion" comes from.

Java
// Business logic welded to one database
public class OrderService {
    private final MySqlOrderRepository repository = new MySqlOrderRepository();

    public void place(Order order) {
        repository.insert(order);          // cannot be tested without MySQL
    }
}
Java
// The interface belongs to the business layer; the database implements it
public interface OrderRepository {
    void save(Order order);
}

public class OrderService {
    private final OrderRepository repository;

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

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

Now OrderService can be tested with an in-memory implementation in microseconds, and switching database is one class. This is the principle every dependency-injection framework exists to support.

Three more worth the same attention

  • DRY — Don't Repeat Yourself. One piece of knowledge, one place. Note the qualifier: identical code expressing two different rules that happen to coincide today should stay separate.
  • KISS — Keep It Simple. The simplest thing that satisfies the requirement, not the most impressive. Clever code has a maintenance cost paid by someone else.
  • YAGNI — You Aren't Gonna Need It. Build for the requirement in front of you. Speculative generality is the most common source of complexity that never pays off.
AspectSmell you noticePrinciple it points at
A class with 800 lines and three unrelated jobsMultiple reasons to changeSingle Responsibility
A switch that grows with every featureModification instead of extensionOpen/Closed
A subclass whose override throwsBroken parent contractLiskov Substitution
UnsupportedOperationException in an implementationInterface too wideInterface Segregation
new ConcreteThing() inside business logicDepending on a detailDependency Inversion
  • A class with 800 lines and three unrelated jobs

    Smell you noticeMultiple reasons to change
    Principle it points atSingle Responsibility
  • A switch that grows with every feature

    Smell you noticeModification instead of extension
    Principle it points atOpen/Closed
  • A subclass whose override throws

    Smell you noticeBroken parent contract
    Principle it points atLiskov Substitution
  • UnsupportedOperationException in an implementation

    Smell you noticeInterface too wide
    Principle it points atInterface Segregation
  • new ConcreteThing() inside business logic

    Smell you noticeDepending on a detail
    Principle it points atDependency Inversion

Working backwards from the smell is faster than working forwards from the acronym.

Common misreadings

  • "Single responsibility means one method." It means one reason to change. Cohesive classes are often large.
  • "Open/closed means never editing a class." Bug fixes are modifications. The principle is about new behaviour not requiring edits to working code.
  • "A Square is a Rectangle, so Liskov is violated by geometry." The violation comes from mutability. Immutable versions substitute fine.
  • "SOLID always improves a design." Applied pre-emptively it produces interfaces with one implementation and indirection with no purpose. These are diagnostics for real smells.
  • "Dependency inversion requires Spring." A constructor parameter is enough.

Quick recall

Everything you need if you only revisit this box.

  • S — one reason to change per class. Split by reason, not by size.
  • O — new behaviour should mean new classes, not edits. Triggered by a growing switch on type.
  • L — a subtype must keep the parent's promises. Triggered by an override that throws or weakens a guarantee.
  • I — no implementer should be forced into methods it cannot honour. Triggered by UnsupportedOperationException.
  • D — depend on an abstraction your layer owns, injected rather than constructed. Inversion is the principle; injection is the technique.
  • DRY, KISS, YAGNI matter just as much, and YAGNI is the brake on over-applying the other five.
  • Work from the smell to the principle, not the acronym to the code.

Test yourself

Answer these before moving on — recall is what makes it stick.