Java SOLID Principles
Apply the SOLID principles — SRP, OCP, LSP, ISP, DIP — to Java design.
SOLID is a set of five object-oriented design principles — popularised by Robert C. Martin — that keep Java code easy to change, test, and extend as it grows. They are not syntax rules the compiler enforces; they are guidelines for where to draw boundaries between classes so that one change does not ripple through the whole codebase. The acronym stands for Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.
These principles build on the object-oriented basics: you will see interfaces, inheritance, and polymorphism used throughout. If those feel shaky, review them first — SOLID is mostly good judgement about where to apply them.
The five principles at a glance
Each letter targets a specific kind of design pain. Keep this table nearby while reading the rest of the chapter:
| Letter | Principle | One-line goal |
|---|---|---|
| S | Single Responsibility | A class should have one reason to change |
| O | Open/Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be usable wherever their base type is |
| I | Interface Segregation | Many small interfaces beat one fat one |
| D | Dependency Inversion | Depend on abstractions, not concrete classes |
The principles reinforce each other. In well-factored code you rarely apply just one — a small interface (ISP) that high-level code depends on (DIP) is exactly what lets you add a new implementation (OCP) without touching the caller.
S — Single Responsibility Principle
A class should do one thing and have one reason to change. When unrelated concerns — business rules and message delivery, say — share a class, a change to either forces you to retest both. Splitting them isolates change.
// Mixes WHEN to alert with HOW to deliver -- two reasons to change.
class BadAlertService {
void raise(String user, int errors) {
if (errors > 0) {
// ...build an email, open an SMTP connection, send...
}
}
}
// One responsibility: deciding when to alert. Delivery lives elsewhere.
class AlertService {
private final Notifier notifier;
AlertService(Notifier notifier) { this.notifier = notifier; }
void raise(String user, int errors) {
if (errors > 0) notifier.send(user, errors + " error(s) detected");
}
}O — Open/Closed Principle
Software entities should be open for extension but closed for modification. You should be able to add new behaviour by writing new code, not by editing — and risking — code that already works. In Java the usual lever is a stable interface plus new implementations.
interface Notifier { void send(String to, String message); }
class EmailNotifier implements Notifier { /* ... */ }
class SmsNotifier implements Notifier { /* ... */ } // new feature = new class
// AlertService never changes when a new channel appears.Adding push notifications later means writing PushNotifier implements Notifier — AlertService is untouched, so it needs no re-review and no regression risk.
L — Liskov Substitution Principle
If S is a subtype of T, then objects of type T can be replaced with objects of type S without breaking the program. A subclass must honour the contract of its parent — same expectations, no surprising exceptions, no stricter preconditions.
abstract class Shape { abstract double area(); }
class Rectangle extends Shape { /* area() = w * h */ }
class Circle extends Shape { /* area() = PI * r * r */ }
// Works for ANY Shape, present or future, without inspecting the concrete type.
double totalArea(List<Shape> shapes) {
return shapes.stream().mapToDouble(Shape::area).sum();
}The classic violation is Square extends Rectangle: if setting the width also mutates the height, code written for a Rectangle breaks when handed a Square. The fix is to model them as siblings under Shape, not a parent-child pair. (See abstract classes for the Shape base used here.)
I — Interface Segregation Principle
Clients should not be forced to depend on methods they do not use. Prefer several small, focused interfaces over one large one — otherwise an implementer is dragged into stubbing methods it cannot honour.
// Fat interface: a read-only source is forced to implement write().
interface Storage { String read(); void write(String data); }
// Segregated: implement only what you can honor.
interface Readable { String read(); }
interface Writable { void write(String data); }
class ConfigFile implements Readable { // no empty write() stub
public String read() { return "mode=prod"; }
}D — Dependency Inversion Principle
High-level modules should not depend on low-level modules; both should depend on abstractions. In practice: code against interfaces and inject the concrete implementation (constructor injection is the simplest form). This is what makes the other principles pay off — and what makes a class testable, since you can pass in a fake.
// AlertService depends on the Notifier interface, not EmailNotifier.
AlertService alerts = new AlertService(new EmailNotifier());
// In a test, inject a fake Notifier and assert on what it recorded.A worked example: all five in one program
This program wires the principles together — a single AlertService (SRP) talks to an injected Notifier (DIP), swaps between an EmailNotifier and an SmsNotifier without changing (OCP), reads a Readable-only ConfigFile (ISP), and sums areas across Shape subtypes uniformly (LSP). It checks its own results so you can see each principle hold.
What to take from the run:
email sent: [EMAIL -> alice: 3 error(s) detected]contains only one entry —bobhad zero errors soraisesent nothing.AlertServiceowns the single responsibility of deciding when to alert (SRP); it never builds a message body or opens a connection.- The same
AlertServiceclass drove both anEmailNotifierand anSmsNotifierbecause it was handed the dependency through its constructor (DIP). The high-level alerting logic depends only on theNotifierinterface, never on a concrete sender. OCP check : ... unchanged = trueconfirms both alert objects are the sameAlertServiceclass: adding SMS support meant writing a newSmsNotifier, with zero edits toAlertService— open for extension, closed for modification.ISP check : is Writable? falseshowsConfigFileimplementsReadableonly. Because the interfaces are segregated, the read-only source was never forced to provide a meaninglesswritestub.LSP area : 9.142is the sum of a 2×3 rectangle (6.0) and a radius-1 circle (≈3.142).totalArealooped overShapereferences and calledarea()without checking which subtype it held — every subtype was substitutable for its base (LSP).