Java SOLID Principles
Apply the SOLID principles — SRP, OCP, LSP, ISP, DIP — to Java design.
Java SOLID Principles
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.
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.
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).
Practice
A class named ReportGenerator both formats report data and writes it to disk, so any change to either the formatting rules or the file layout forces you to modify and retest the same class. Which SOLID principle does this most directly violate?