Skip to main content
Innovation|Innovation

SOLID Principles in Java: Five Rules That Make Code Easy to Change

A practical, code-first walkthrough of the five SOLID principles in Java — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — with messy-then-clean examples and honest guidance on when to actually apply them.

May 6, 202611 min read1 views0 comments
Share:

Most code does not break because it is wrong. It breaks because it is hard to change. SOLID is five small rules for the second problem.

Why SOLID Still Matters

I remember the first time I tried to add a feature to a codebase that someone else had written. I changed three lines in one file, and somewhere across the project, four tests went red. The code was not technically wrong. It just had not been written with the next person in mind.

SOLID is the answer to that experience. It is five principles, named by Robert C. Martin in the early 2000s, that help object-oriented code stay flexible as it grows. They are not laws. They are not even always the right call on day one. But after about year three of maintaining anything non-trivial in Java, you start to feel why they exist.

The acronym stands for:

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

This guide walks through each one with a concrete Java example: the messy version, the SOLID version, and a one-line answer to "when would I actually use this?" The examples are deliberately small — patterns get easier to recognise once you have seen them at the size of a sketch.

S — Single Responsibility Principle

A class should have one reason to change.

The Problem

Take a class that does too much. It looks reasonable in the editor, but every requirement change touches it.

public class Invoice {
    private List<LineItem> items;
    private Customer customer;

    public double calculateTotal() {
        double total = 0;
        for (LineItem item : items) {
            total += item.getPrice() * item.getQuantity();
        }
        return total;
    }

    public String generatePdf() {
        // builds a PDF using iText
        return "...pdf bytes...";
    }

    public void sendEmail(String address) {
        // opens an SMTP connection, attaches the PDF, sends
    }

    public void saveToDatabase() {
        // opens a JDBC connection, runs INSERT
    }
}

Four reasons this class can change: pricing rules shift, the PDF library is replaced, marketing wants different email templates, the database is migrated from MySQL to Postgres. Four teams could be editing the same file at once.

The Fix

Split by responsibility. Each class owns one concern.

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

    public Invoice(List<LineItem> items, Customer customer) {
        this.items = items;
        this.customer = customer;
    }

    public double calculateTotal() {
        return items.stream()
            .mapToDouble(i -> i.getPrice() * i.getQuantity())
            .sum();
    }

    public List<LineItem> getItems() { return items; }
    public Customer getCustomer() { return customer; }
}

public class InvoicePdfRenderer {
    public byte[] render(Invoice invoice) { /* ... */ }
}

public class InvoiceEmailer {
    public void send(Invoice invoice, String address, byte[] pdf) { /* ... */ }
}

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

Now each change has a single home. If the SMTP server moves, only InvoiceEmailer opens.

When to Reach For It

The day a class starts collecting and in its job description — "calculates totals and sends emails and writes to the DB" — split it. The shape of "and" sentences is the cleanest signal SRP gives you.

O — Open/Closed Principle

Code should be open for extension, closed for modification.

The Problem

You wrote a discount calculator. Every new customer tier means another if branch in the same method.

public class DiscountCalculator {
    public double apply(Customer customer, double total) {
        if (customer.getTier().equals("REGULAR")) {
            return total;
        } else if (customer.getTier().equals("PREMIUM")) {
            return total * 0.90;
        } else if (customer.getTier().equals("VIP")) {
            return total * 0.80;
        } else if (customer.getTier().equals("EMPLOYEE")) {
            return total * 0.70;
        }
        return total;
    }
}

Adding a "STUDENT" tier means editing this class. Editing means re-testing every existing tier. The class is not closed against change.

The Fix

Push each rule behind an interface. New tiers arrive as new classes.

public interface DiscountPolicy {
    boolean appliesTo(Customer customer);
    double apply(double total);
}

public class RegularDiscount implements DiscountPolicy {
    public boolean appliesTo(Customer c) { return c.getTier().equals("REGULAR"); }
    public double apply(double total) { return total; }
}

public class PremiumDiscount implements DiscountPolicy {
    public boolean appliesTo(Customer c) { return c.getTier().equals("PREMIUM"); }
    public double apply(double total) { return total * 0.90; }
}

public class DiscountCalculator {
    private final List<DiscountPolicy> policies;

    public DiscountCalculator(List<DiscountPolicy> policies) {
        this.policies = policies;
    }

    public double apply(Customer customer, double total) {
        return policies.stream()
            .filter(p -> p.appliesTo(customer))
            .findFirst()
            .map(p -> p.apply(total))
            .orElse(total);
    }
}

Adding the student tier is a new file: StudentDiscount implements DiscountPolicy. Existing classes are untouched. With Spring, you would just register it as a @Component and let the framework inject the list.

When to Reach For It

When you can see a sequence of else if branches that grow whenever a new variant of "the same kind of thing" arrives, that growth is the smell. Each branch wants to be a class.

L — Liskov Substitution Principle

Subclasses must be usable in place of their parents without surprises.

The Problem

The classic example is a Rectangle and a Square. A square is a rectangle in geometry, so why not in code?

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

Now write a method that uses a rectangle:

void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20;  // FAILS for Square — area is 16
}

The square satisfies extends Rectangle, but it breaks every caller that assumed width and height are independent. The inheritance is a lie.

The Fix

Stop forcing the relationship. Both shapes implement a common interface; neither inherits from the other.

public interface Shape {
    int area();
}

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

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

Now Shape says only what is honestly true: every shape has an area. No caller is misled.

When to Reach For It

Whenever you reach for extends, ask: can I use the child anywhere the parent works without changing the calling code? If you have to override methods to throw UnsupportedOperationException, you are not doing inheritance — you are doing apology. Reach for an interface or composition instead.

I — Interface Segregation Principle

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

The Problem

One enormous interface that every implementer has to satisfy.

public interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
    void writeCode();
    void writeReport();
}

public class Robot implements Worker {
    public void work() { /* OK */ }
    public void writeCode() { /* OK */ }

    public void eat() { throw new UnsupportedOperationException(); }
    public void sleep() { throw new UnsupportedOperationException(); }
    public void attendMeeting() { throw new UnsupportedOperationException(); }
    public void writeReport() { throw new UnsupportedOperationException(); }
}

The robot is forced to lie about half its methods. Worse, every time the interface grows, every implementer breaks until they add another empty method.

The Fix

Many small interfaces, picked up only by the classes that actually need them.

public interface Workable { void work(); }
public interface Eatable   { void eat(); }
public interface Sleepable { void sleep(); }
public interface Coder     { void writeCode(); }

public class Robot implements Workable, Coder {
    public void work() { /* ... */ }
    public void writeCode() { /* ... */ }
}

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

Each class implements exactly the contracts that match its nature. No throwing exceptions to satisfy a parent that asked for too much.

When to Reach For It

The smell is empty methods, UnsupportedOperationException, or comments that say "not applicable for this type." Each of those is the interface telling you it should have been split a long time ago.

D — Dependency Inversion Principle

Depend on abstractions, not on concrete classes.

The Problem

A high-level class reaches directly into a low-level one.

public class MySqlOrderRepository {
    public void save(Order order) {
        // JDBC calls to MySQL
    }
}

public class OrderService {
    private final MySqlOrderRepository repository = new MySqlOrderRepository();

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

The service is welded to MySQL. Swapping the database means editing OrderService. Writing a unit test means standing up a real MySQL instance. The high-level policy (placing orders) is at the mercy of a low-level detail (which database).

The Fix

The service depends on an interface; the database choice is a detail injected from outside.

public interface OrderRepository {
    void save(Order order);
}

public class MySqlOrderRepository implements OrderRepository {
    public void save(Order order) { /* ... */ }
}

public class PostgresOrderRepository implements OrderRepository {
    public void save(Order order) { /* ... */ }
}

public class InMemoryOrderRepository implements OrderRepository {
    private final List<Order> saved = new ArrayList<>();
    public void save(Order order) { saved.add(order); }
    public List<Order> all() { return saved; }
}

public class OrderService {
    private final OrderRepository repository;

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

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

In production you wire in PostgresOrderRepository. In a unit test you wire in InMemoryOrderRepository and assert against all(). The business rules never noticed.

When to Reach For It

Anywhere the word new appears next to something the class should not care about — a database driver, an HTTP client, a clock, a random number generator. That new is a hidden dependency. Replace it with a constructor parameter and an interface, and the testability and replaceability come for free.

How the Five Work Together

The principles are not five separate exercises. They are five views of the same idea: code that gives you somewhere to plug in changes.

  • SRP defines the unit. One reason to change.
  • OCP defines the seam. New behaviour goes in a new class.
  • LSP keeps the seam honest. Substitutability you can rely on.
  • ISP keeps the seam narrow. Do not make me depend on what I do not need.
  • DIP points the seam outward. Policy depends on abstraction; details plug in from outside.

Together they describe a small, well-mannered class with a focused job, a clear contract, and a readable wiring diagram. Spring Boot, Quarkus, and most of the Java frameworks people reach for daily are SOLID factories — they make doing the right thing the path of least resistance.

Common Mistakes

Over-applying on day one. A weekend project with one developer and three classes does not need an interface for every collaborator. SOLID earns its keep when the codebase outlives its first author. Premature abstraction is its own kind of debt.

Mistaking SRP for "one method per class". A class can have many methods if they all serve the same responsibility. SRP is about reasons to change, not method count.

Treating LSP as an inheritance puzzle. The deeper rule is "honour the contract the parent promised." If your subclass weakens a guarantee — narrows the accepted inputs, throws a new exception, mutates state the parent did not — callers break.

Assuming DI containers do DIP for you. @Autowired on a concrete class is not DIP. The principle is about depending on abstractions; the framework just makes the wiring less verbose.

Splitting interfaces too aggressively. ISP is not "one method per interface." If three methods always travel together in real callers, they belong together. The smell is unused methods at call sites, not the interface having more than one method.

A Last Thought

SOLID was named in 2000 in a paper that mostly described practices people had already been muddling toward for a decade. That is the honest origin story of every good engineering principle: someone watched a lot of code rot, named the shapes that rotted slowest, and wrote them down. The point is never to memorise the acronym. The point is to recognise the shapes — the four-job class, the growing if-ladder, the lying subclass, the bloated interface, the welded dependency — quickly enough that fixing them is cheap.

Software does not have to age into something fragile. It can age into something that future-you, or the next person, can pick up on a Tuesday morning and change without flinching. That is what these five rules buy you.

Frequently Asked Questions

Do I need to apply SOLID to every class?

No. SOLID earns its weight in code that will be edited many times, by many people, for a long time. A throwaway script or a 50-line utility does not need a constructor-injected abstraction layer. Use the principles when complexity or change pressure shows up — the cost of refactoring later is real, but so is the cost of premature abstraction now.

How is SOLID different from design patterns like Strategy or Factory?

SOLID is the why. Design patterns are common shapes for satisfying SOLID. The Strategy pattern is one way to obey OCP. Dependency injection is one way to obey DIP. The Adapter pattern is often a fix for an LSP violation. Patterns without principles become cargo cult; principles without patterns leave you reinventing the wheel.

Does Spring Boot make my code automatically SOLID?

It removes some of the friction, but it does not enforce the principles. @Autowired on a concrete class still couples you to that class. @Service on a 1,000-line god-class still violates SRP. Spring is a tool — the principles tell you whether you are using it well.

What is the relationship between SOLID and clean architecture?

Clean architecture (and the related "ports and adapters" / hexagonal style) is what SOLID looks like at the system level. SRP becomes "domain logic in one layer, infrastructure in another." DIP becomes "the domain depends on interfaces; adapters plug in at the edges." If SOLID is the grammar, clean architecture is the paragraph.

How do I know I have gone too far with abstractions?

Three signs: you have to read three files to follow what one method does, your interfaces have one implementation and no plausible second one, and onboarding a new teammate takes a long time before they can change anything. Abstractions earn their cost when they hide a real second variation. If they do not, deleting a layer often reads as a relief.


Comments


Login to join the conversation.

Loading comments…

More from Innovation