SOLID Principles in Java: Overview with Examples

Introduction

This tutorial is an overview of the five SOLID principles of object-oriented design, with Java examples. We will start by looking at what SOLID stands for and why the principles exist. We will then take one small Java class that breaks most of them, and refactor it step by step, seeing each principle at work. Lastly, we will look at how the five principles fit together, how they look in modern Java, and the mistakes to avoid when applying them.

Each principle also has a detailed tutorial of its own on JavaBrahman, linked from its section below. Read this overview first to see the big picture, and the detailed tutorials when you want to go deeper.

What are the SOLID principles

SOLID is an acronym for five design principles that help keep object-oriented code easy to change. Robert C. Martin collected them in his 2000 paper Design Principles and Design Patterns, and Michael Feathers later arranged their initials into the word SOLID. The five principles are -
  • S - Single Responsibility Principle (SRP): a class should have only one reason to change.
  • O - Open Closed Principle (OCP): software entities should be open for extension, but closed for modification.
  • L - Liskov Substitution Principle (LSP): objects of a subtype must be usable wherever objects of the base type are expected, without breaking the program.
  • I - Interface Segregation Principle (ISP): clients should not be forced to depend on methods they do not use.
  • D - Dependency Inversion Principle (DIP): high-level modules should not depend on low-level modules; both should depend on abstractions.
SOLID principles overview - Single Responsibility, Open Closed, Liskov Substitution, Interface Segregation and Dependency Inversion

Why SOLID matters

Code is read and changed far more often than it is written. A design that is hard to change shows the same symptoms again and again: a small change breaks something unrelated (fragility), one change forces edits in many classes (rigidity), and useful code cannot be reused because it drags its dependencies along (immobility).

The SOLID principles target exactly these symptoms. They do not tell you what your classes should be; they tell you how to arrange dependencies and responsibilities so that the next requirement means adding a class rather than rewriting several.

The starting point - a class that breaks SOLID

Let us take a payroll report that lists each employee's annual salary. The report must come out as plain text or as CSV. Here is a first version, written the quick way.
Java code of a PayrollReport class that breaks the SOLID principles
//PayrollBefore.java
import java.util.List;

public class PayrollBefore {

  record Employee(String name, double monthlySalary) {}

  static class PayrollReport {
    String generate(String format) {
      List<Employee> employees = List.of(
          new Employee("Tom Jones", 5200),
          new Employee("Harry Major", 4100),
          new Employee("Ethan Hardy", 6300));
      StringBuilder out = new StringBuilder();
      for (Employee e : employees) {
        double annual = e.monthlySalary() * 12;
        if (format.equals("TEXT")) {
          out.append(e.name()).append(" earns ").append(annual).append('\n');
        } else if (format.equals("CSV")) {
          out.append(e.name()).append(',').append(annual).append('\n');
        } else {
          throw new IllegalArgumentException("Unknown format " + format);
        }
      }
      return out.toString();
    }
  }

  public static void main(String[] args) {
    PayrollReport report = new PayrollReport();
    System.out.print(report.generate("TEXT"));
    System.out.print(report.generate("CSV"));
  }
}
 OUTPUT of the above code
Tom Jones earns 62400.0
Harry Major earns 49200.0
Ethan Hardy earns 75600.0
Tom Jones,62400.0
Harry Major,49200.0
Ethan Hardy,75600.0

What is wrong with it

The code works, but look at what happens to it over time -
  • It has three reasons to change: where the employees come from, how pay is calculated, and how the report is formatted. Three different people can ask for three different changes to the same method. This breaks the Single Responsibility Principle.
  • A new format means editing it: adding JSON means another else if inside generate(), and re-testing TEXT and CSV too. This breaks the Open Closed Principle.
  • It depends on details: the employee list is created inside the report, so it cannot be tested with other data or pointed at a database later. This breaks the Dependency Inversion Principle.
  • The format is a String: a typo such as "Csv" compiles fine and fails only at runtime.

Refactoring the report with SOLID

We now split the class along its reasons to change. Fetching employees goes behind an EmployeeSource interface, formatting a line goes behind a ReportFormatter interface, the salary calculation moves to the Employee record that owns the data, and PayrollReport is left with one job: putting the lines together.
Payroll report before and after applying SOLID principles - one class doing everything versus small classes joined by interfaces
Java code of the PayrollReport refactored with SOLID principles
//PayrollAfter.java
import java.util.List;

public class PayrollAfter {

  record Employee(String name, double monthlySalary) {
    double annualSalary() { return monthlySalary * 12; }
  }

  // D: the report depends on these two abstractions
  interface EmployeeSource { List<Employee> employees(); }
  interface ReportFormatter { String line(Employee e); }

  static class InMemorySource implements EmployeeSource {
    public List<Employee> employees() {
      return List.of(new Employee("Tom Jones", 5200),
          new Employee("Harry Major", 4100),
          new Employee("Ethan Hardy", 6300));
    }
  }

  // O: a new format is a new class; PayrollReport is not edited
  static class TextFormatter implements ReportFormatter {
    public String line(Employee e) { return e.name() + " earns " + e.annualSalary(); }
  }
  static class CsvFormatter implements ReportFormatter {
    public String line(Employee e) { return e.name() + "," + e.annualSalary(); }
  }

  // S: the report only puts the lines together
  static class PayrollReport {
    private final EmployeeSource source;
    private final ReportFormatter formatter;

    PayrollReport(EmployeeSource source, ReportFormatter formatter) {
      this.source = source;
      this.formatter = formatter;
    }

    String generate() {
      StringBuilder out = new StringBuilder();
      for (Employee e : source.employees()) {
        out.append(formatter.line(e)).append('\n');
      }
      return out.toString();
    }
  }

  public static void main(String[] args) {
    EmployeeSource source = new InMemorySource();
    System.out.print(new PayrollReport(source, new TextFormatter()).generate());
    System.out.print(new PayrollReport(source, new CsvFormatter()).generate());
    // A JSON format added later, without touching PayrollReport
    ReportFormatter json =
        e -> "{\"name\":\"" + e.name() + "\",\"annual\":" + e.annualSalary() + "}";
    System.out.print(new PayrollReport(source, json).generate());
  }
}
 OUTPUT of the above code
Tom Jones earns 62400.0
Harry Major earns 49200.0
Ethan Hardy earns 75600.0
Tom Jones,62400.0
Harry Major,49200.0
Ethan Hardy,75600.0
{"name":"Tom Jones","annual":62400.0}
{"name":"Harry Major","annual":49200.0}
{"name":"Ethan Hardy","annual":75600.0}

Explanation of the code

  • PayrollReport no longer knows where employees come from or what the output looks like. It changes only if the way a report is assembled changes - its single responsibility.
  • annualSalary() moved to Employee, the class that holds the salary. Pay rules now change in one place.
  • The JSON format in main() was added without editing a single existing class. PayrollReport is closed for modification yet open for extension.
  • PayrollReport receives its EmployeeSource and ReportFormatter through its constructor. It depends on abstractions, so a database source or a test source with fixed data can be plugged in without changing it.
  • Because ReportFormatter has a single abstract method, it is a functional interface, and the JSON formatter is just a lambda.

S - Single Responsibility Principle

A class should have one reason to change. "Reason to change" means a source of change - usually a person or a team who asks for it. In the example, the payroll team owns the salary rules and the reporting team owns the formats; mixing them in one class means their changes collide.

A quick test: describe what the class does in one sentence without using "and". If you cannot, it probably has more than one responsibility. Read more in the Single Responsibility Principle tutorialClick to read: Single Responsibility Principle tutorial.

O - Open Closed Principle

A class should be open for extension but closed for modification: new behaviour comes from adding code, not from editing code that already works and is tested. The usual tool is an interface with several implementations, as ReportFormatter shows.

The warning sign is a switch or an if-else chain on a type code that keeps growing. Read more in the Open Closed Principle tutorialClick to read: Open Closed Principle tutorial.

L - Liskov Substitution Principle

Any implementation of an interface, or subclass of a class, must honour the contract of its parent, so that callers never need to know which one they got. If a caller has to write if (x instanceof Special) to avoid a crash, substitution is broken.

The JDK itself has a well-known example. List.of() returns a List, but calling add() on it throws an exception -
An unmodifiable List cannot be substituted where a modifiable List is expected
List<String> names = List.of("Tom Jones", "Harry Major");
names.add("Nancy Smith"); // throws UnsupportedOperationException
The List interface documents add() as an optional operation, so this is legal, but a method that receives a List and adds to it will fail when handed this one. Read more in the Liskov Substitution Principle tutorialClick to read: Liskov Substitution Principle tutorial.

I - Interface Segregation Principle

Clients should not depend on methods they do not use. A large interface forces every implementation to provide every method, even with an empty body or an exception, and every client is affected when any method changes.
A fat interface split into role interfaces
// Before: every implementation must support every method
interface EmployeeStore {
  Employee find(String id);
  void save(Employee e);
  void delete(String id);
  byte[] exportToExcel();
}

// After: clients depend only on the role they need
interface EmployeeFinder   { Employee find(String id); }
interface EmployeeWriter   { void save(Employee e); void delete(String id); }
interface EmployeeExporter { byte[] exportToExcel(); }
A read-only report now depends on EmployeeFinder alone, and is unaffected when the export code changes. Read more in the Interface Segregation Principle tutorialClick to read: Interface Segregation Principle tutorial.

D - Dependency Inversion Principle

High-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the policy. In the example, PayrollReport (the policy) defines what it needs - EmployeeSource - and the data access code implements it. The dependency arrow now points from the detail to the policy: it has been inverted.

Dependency inversion is often confused with dependency injection; the two are related but not the same. Read more in the Dependency Inversion Principle tutorialClick to read: Dependency Inversion Principle tutorial and in Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC.

How the five principles work together

The principles are not five separate rules; they support each other -
  • SRP and ISP keep things small: SRP for classes, ISP for interfaces. Small pieces are what make the other three possible.
  • OCP is the goal: adding behaviour without editing working code.
  • DIP and LSP are the means: DIP puts an interface between the policy and the details, and LSP makes sure every implementation behind that interface can be trusted.

SOLID in modern Java

Newer Java features make the principles cheaper to follow -
  • Lambdas: a one-method strategy, such as ReportFormatter, needs no class at all.
  • Records: small, immutable data classes with behaviour, such as Employee.annualSalary(), cost one line each.
  • Sealed interfaces: when the set of implementations is deliberately fixed, a sealed interface with a switch over its types is a valid alternative to OCP, and the compiler checks that every case is handled.
  • Default methods: let an interface grow without breaking existing implementations.

Common mistakes when applying SOLID

  • An interface for everything: an interface with exactly one implementation that will never have a second one adds indirection and no flexibility. Introduce it when a second implementation, or a test double, actually appears.
  • Splitting too finely: one method per class is not SRP. Split by reason to change, not by line count.
  • Designing for imagined change: OCP is for the variation you can see coming. Guessing every future change leads to over-engineering - see DRY, KISS and YAGNIClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples.
  • Treating them as laws: they are heuristics. Break one knowingly when the simpler design is clearer.

Summary

In this tutorial we first saw what the five SOLID principles are and the design problems they address. Next we took a payroll report that mixed data, calculation and formatting in one method, and refactored it so that each class has one job, new formats are added as new classes, and the report depends only on interfaces. We then looked at each principle in turn, including a Liskov substitution trap in the JDK's own List.of() and a fat interface split into role interfaces. Lastly, we saw how the principles support each other, how lambdas, records and sealed types help, and the mistakes to avoid when applying them.