Skip to main content
Java · lesson 24 of 66
Open the Java page →
Java advanced Lesson 24 of 66

Also part of: Low-Level Design

SOLID Principles in Java

Learn the five SOLID design principles by running Java examples: each shows the violation, what breaks, and the refactor that fixes it.

By SkillByExample · Updated

SOLID names five design principles, collected by Robert C. Martin, that keep object-oriented code easy to change and test. Each section below shows a small violation, runs it so you can see what goes wrong, then runs the refactor. Every example is a single file run with the JDK 21 source launcher (java File.java).

S — Single Responsibility

A class should have one, and only one, reason to change.

import java.util.ArrayList;
import java.util.List;

public class SrpDemo {
    record Item(String name, double price, int qty) {
        double subtotal() { return price * qty; }
    }

    static class Order {                       // domain rules only
        final List<Item> items = new ArrayList<>();
        void add(String name, double price, int qty) { items.add(new Item(name, price, qty)); }
        double total() { return items.stream().mapToDouble(Item::subtotal).sum(); }
    }

    static class InvoiceFormatter {            // presentation only
        String format(Order o) {
            StringBuilder sb = new StringBuilder();
            for (Item i : o.items) sb.append("%-8s %d x %6.2f%n".formatted(i.name(), i.qty(), i.price()));
            return sb.append("TOTAL %15.2f".formatted(o.total())).toString();
        }
    }

    static class Notifier {                    // delivery only
        void send(String to, String body) { System.out.println("to " + to + ":\n" + body); }
    }

    public static void main(String[] args) {
        Order order = new Order();
        order.add("Widget", 25.0, 2);
        order.add("Gadget", 15.0, 1);
        new Notifier().send("ada@example.com", new InvoiceFormatter().format(order));
    }
}
$ java SrpDemo.java
to ada@example.com:
Widget   2 x  25.00
Gadget   1 x  15.00
TOTAL           65.00

If Order also formatted invoices and sent mail, a change to the email provider or the invoice layout would mean editing — and retesting — the class that computes totals. Split along those reasons to change, each class can be tested alone: Order with no I/O at all, InvoiceFormatter with a hand-built order.

O — Open/Closed

Open for extension, closed for modification.

import java.util.List;

public class OcpDemo {
    interface Discount {
        double apply(double price);
        String description();
    }
    record Percentage(double pct) implements Discount {
        public double apply(double p) { return p * (1 - pct / 100); }
        public String description() { return pct + "% off"; }
    }
    record Flat(double amount) implements Discount {
        public double apply(double p) { return Math.max(0, p - amount); }
        public String description() { return "$" + amount + " off"; }
    }
    record HalfPrice() implements Discount {   // added later: no existing code edited
        public double apply(double p) { return p / 2; }
        public String description() { return "half price"; }
    }

    static double checkout(double total, Discount d) { return d.apply(total); }

    public static void main(String[] args) {
        for (Discount d : List.of(new Percentage(10), new Flat(10), new HalfPrice()))
            System.out.printf("%-12s -> %.2f%n", d.description(), checkout(65.0, d));
    }
}
$ java OcpDemo.java
10.0% off    -> 58.50
$10.0 off    -> 55.00
half price   -> 32.50

The alternative — if (type.equals("PERCENT")) ... else if (type.equals("FLAT")) inside checkout — needs an edit to tested code for every new discount. Here checkout only knows the Discount interface, so HalfPrice arrived as a new class and nothing that already worked was touched.

L — Liskov Substitution

Subtypes must be usable anywhere their parent type is expected.

public class LspBugDemo {
    static class Rectangle {
        protected double width, height;
        void setWidth(double w)  { width = w; }
        void setHeight(double h) { height = h; }
        double area() { return width * height; }
    }

    static class Square extends Rectangle {
        @Override void setWidth(double s)  { width = s; height = s; }
        @Override void setHeight(double s) { width = s; height = s; }
    }

    static void resize(Rectangle r) {           // written against Rectangle's contract
        r.setWidth(5);
        r.setHeight(3);
        System.out.println(r.getClass().getSimpleName() + " area = " + r.area() + " (expected 15.0)");
    }

    public static void main(String[] args) {
        resize(new Rectangle());
        resize(new Square());
    }
}
$ java LspBugDemo.java
Rectangle area = 15.0 (expected 15.0)
Square area = 9.0 (expected 15.0)

resize relies on width and height being independent, which Rectangle promises. Square compiles fine as a subclass but breaks that promise: setHeight(3) silently rewrites the width too. A square is a rectangle mathematically, but not a mutable Rectangle behaviourally.

import java.util.List;

public class LspFixDemo {
    interface Shape { double area(); }
    record Rectangle(double width, double height) implements Shape {
        public double area() { return width * height; }
    }
    record Square(double side) implements Shape {
        public double area() { return side * side; }
    }

    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Rectangle(5, 3), new Square(3));
        for (Shape s : shapes) System.out.println(s + " area = " + s.area());
    }
}
$ java LspFixDemo.java
Rectangle[width=5.0, height=3.0] area = 15.0
Square[side=3.0] area = 9.0

Making both immutable siblings under a Shape interface removes the false promise: there are no setters to misbehave, and every Shape honours the one contract it declares — area().

I — Interface Segregation

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

public class IspDemo {
    interface AllInOne { void print(String doc); void scan(String doc); void fax(String doc); }

    static class BasicPrinter implements AllInOne {
        public void print(String doc) { System.out.println("printing " + doc); }
        public void scan(String doc)  { throw new UnsupportedOperationException("scan"); }
        public void fax(String doc)   { throw new UnsupportedOperationException("fax"); }
    }

    interface Printer { void print(String doc); }
    interface Scanner { void scan(String doc); }
    record SimplePrinter() implements Printer {
        public void print(String doc) { System.out.println("printing " + doc); }
    }

    public static void main(String[] args) {
        AllInOne fat = new BasicPrinter();
        fat.print("report.pdf");
        try { fat.scan("report.pdf"); }
        catch (UnsupportedOperationException e) { System.out.println("runtime surprise: " + e); }

        Printer lean = new SimplePrinter();
        lean.print("report.pdf");
        // lean.scan("report.pdf");   // would not compile: Printer has no scan method
    }
}
$ java IspDemo.java
printing report.pdf
runtime surprise: java.lang.UnsupportedOperationException: scan
printing report.pdf

The fat interface forces BasicPrinter to stub methods it cannot support, and the mistake only shows up when someone calls them at runtime. With narrow Printer/Scanner interfaces a class implements exactly what it can do, and calling scan on a Printer is a compile error instead.

D — Dependency Inversion

Depend on abstractions, not concretions.

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class DipDemo {
    record User(long id, String name) {}

    interface UserRepository { Optional<User> findById(long id); }

    static class InMemoryUserRepository implements UserRepository {
        private final Map<Long, User> users = new HashMap<>(Map.of(1L, new User(1, "Ada")));
        public Optional<User> findById(long id) { return Optional.ofNullable(users.get(id)); }
    }

    static class UserService {
        private final UserRepository repo;
        UserService(UserRepository repo) { this.repo = repo; }      // injected, not new'd
        String greet(long id) { return repo.findById(id).map(u -> "Hello, " + u.name()).orElse("Unknown user " + id); }
    }

    public static void main(String[] args) {
        UserService real = new UserService(new InMemoryUserRepository());
        System.out.println(real.greet(1) + " | " + real.greet(2));

        UserService stubbed = new UserService(id -> Optional.of(new User(id, "Test User")));
        System.out.println(stubbed.greet(42));
    }
}
$ java DipDemo.java
Hello, Ada | Unknown user 2
Hello, Test User

UserService never names a concrete repository, so the same greet code runs against an in-memory store, a real database class, or — in a test — a lambda. The lambda works because UserRepository has a single abstract method; add save to it and the stub needs a full class, one more reason to keep interfaces small.

How the five fit together

PrincipleIn the examples
SRPOrder, InvoiceFormatter, Notifier each change for one reason
OCPNew Discount types arrive as new classes
LSPSquare and Rectangle share an honest Shape contract
ISPPrinter and Scanner instead of one fat device interface
DIPUserService gets its UserRepository injected

Practice

  1. Add a Stacked(Discount first, Discount second) discount that applies both in order, without changing Discount, checkout, or any existing discount.
Solution
public class StackedDiscount {
    interface Discount { double apply(double price); }
    record Percentage(double pct) implements Discount {
        public double apply(double p) { return p * (1 - pct / 100); }
    }
    record Flat(double amount) implements Discount {
        public double apply(double p) { return Math.max(0, p - amount); }
    }
    record Stacked(Discount first, Discount second) implements Discount {
        public double apply(double p) { return second.apply(first.apply(p)); }
    }

    public static void main(String[] args) {
        System.out.printf("%.2f%n", new Stacked(new Percentage(10), new Flat(10)).apply(65.0));
        System.out.printf("%.2f%n", new Stacked(new Flat(10), new Percentage(10)).apply(65.0));
    }
}
$ java StackedDiscount.java
48.50
49.50

Order matters: 10% then $10 off is not the same as $10 then 10%. Stacked is itself a Discount, so stacks can be nested.

  1. Give DipDemo a second implementation that records every lookup, and use it in place of the in-memory one to check which ids greet asked for — without changing UserService.
Solution
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class RecordingRepo {
    record User(long id, String name) {}
    interface UserRepository { Optional<User> findById(long id); }

    static class UserService {
        private final UserRepository repo;
        UserService(UserRepository repo) { this.repo = repo; }
        String greet(long id) { return repo.findById(id).map(u -> "Hello, " + u.name()).orElse("Unknown user " + id); }
    }

    public static void main(String[] args) {
        List<Long> lookups = new ArrayList<>();
        UserRepository spy = id -> { lookups.add(id); return Optional.empty(); };
        UserService service = new UserService(spy);
        service.greet(7);
        service.greet(9);
        System.out.println("looked up: " + lookups);
    }
}
$ java RecordingRepo.java
looked up: [7, 9]

This hand-written “spy” is exactly what mocking libraries such as Mockito generate for you.

  1. Split AllInOne so a MultiFunctionDevice can implement both Printer and Scanner, and write a method that accepts only things that can scan.
Solution
public class MultiFunction {
    interface Printer { void print(String doc); }
    interface Scanner { String scan(String doc); }

    static class MultiFunctionDevice implements Printer, Scanner {
        public void print(String doc) { System.out.println("printing " + doc); }
        public String scan(String doc) { return "scan-of-" + doc; }
    }

    static void archive(Scanner s, String doc) { System.out.println("archived " + s.scan(doc)); }

    public static void main(String[] args) {
        MultiFunctionDevice mfd = new MultiFunctionDevice();
        mfd.print("contract.pdf");
        archive(mfd, "contract.pdf");
    }
}
$ java MultiFunction.java
printing contract.pdf
archived scan-of-contract.pdf

archive asks for the smallest capability it needs, so any scanner works — including one that cannot print.

Frequently Asked Questions

Do I have to follow all five SOLID principles all the time?
No. They are guidelines for keeping code easy to change, not rules to apply mechanically. A 50-line utility class does not need to be split into five interfaces. Use SOLID as a diagnostic when a change ripples through unrelated code, a class is hard to test, or a subclass surprises its callers.
What is the most important SOLID principle?
Many developers find Dependency Inversion pays off first, because injecting dependencies through interfaces is what makes classes testable without a database or network. The Open/Closed Principle has the biggest long-term effect on stability, since new behaviour arrives as new classes instead of edits to tested code.
What does the Liskov Substitution Principle mean in practice?
A subclass must keep the promises its parent makes to callers, not just its method signatures. If code written against the parent type gives a wrong answer or throws when handed a subclass, the hierarchy is wrong. The classic example is a Square extending a mutable Rectangle, where setting the height silently changes the width.
Is SOLID only for object-oriented code?
The principles were stated for classes, but the ideas carry over. Single responsibility applies to functions and modules, interface segregation to APIs, and dependency inversion to any system where high-level policy should not be wired to low-level details such as a specific database or HTTP client.