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.
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"));
}
}
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 ifinsidegenerate(), 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 anEmployeeSource 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.
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());
}
}
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
PayrollReportno 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 toEmployee, 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.PayrollReportis closed for modification yet open for extension. PayrollReportreceives itsEmployeeSourceandReportFormatterthrough 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
ReportFormatterhas 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, asReportFormatter 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 writeif (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
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(); }
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
switchover 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 ownList.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.Programming Principles Tutorials
Composition over Inheritance in Java with ExamplesClick to read tutorial: Composition over Inheritance in Java with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC Law of Demeter in Java Explained with ExamplesClick to read tutorial: Law of Demeter in Java Explained with Examples Tell, Don't Ask Principle Explained with Java ExamplesClick to read tutorial: Tell, Don't Ask Principle Explained with Java Examples Separation of Concerns Explained with Java ExamplesClick to read tutorial: Separation of Concerns Explained with Java Examples
Composition over Inheritance in Java with ExamplesClick to read tutorial: Composition over Inheritance in Java with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC Law of Demeter in Java Explained with ExamplesClick to read tutorial: Law of Demeter in Java Explained with Examples Tell, Don't Ask Principle Explained with Java ExamplesClick to read tutorial: Tell, Don't Ask Principle Explained with Java Examples Separation of Concerns Explained with Java ExamplesClick to read tutorial: Separation of Concerns Explained with Java Examples