If you’ve ever opened your own Java code six months later and sighed, “Who wrote this?”—surprise, it was you—then SOLID is your friend. The SOLID principles are five simple rules that help you write code that’s easier to read, change, and test. They don’t promise unicorns, but they do keep future-you a lot happier.
SOLID is not dogma; it’s a set of heuristics. Use them to guide decisions, not to win arguments.
In this article, we’ll walk through each principle with Java examples, common smells, and quick wins you can use immediately.
Quick SOLID Overview
- Single Responsibility Principle (SRP): A class should have one reason to change.
- Open/Closed Principle (OCP): Software entities should be open for extension, but closed for modification.
- Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without breaking behavior.
- Interface Segregation Principle (ISP): Many client-specific interfaces are better than one general-purpose interface.
- Dependency Inversion Principle (DIP): Depend on abstractions, not concretions.
For a deeper dive into the background, check out the original ideas popularized by Robert C. Martin and communities around SOLID on Wikipedia.
Single Responsibility Principle (SRP)
One class, one job. If a class is doing multiple things, testing and changes become risky and painful.
What it means
A class should have exactly one reason to change. If your class both calculates, persists, and prints invoices, it’s wearing too many hats.
Common smells
- A method list that scrolls off your IDE.
- Fields that are used by only some methods.
- You can’t reuse logic without dragging unrelated dependencies.
A clean SRP example
Let’s say we’re working with invoices. We’ll separate calculation, persistence, and presentation.
import java.math.BigDecimal;
import java.util.List;
class LineItem {
private final String description;
private final int quantity;
private final BigDecimal unitPrice;
public LineItem(String description, int quantity, BigDecimal unitPrice) {
this.description = description;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
public String description() { return description; }
}
class Invoice {
private final String id;
private final List items;
private final BigDecimal taxRate; // e.g., 0.2 for 20%
public Invoice(String id, List items, BigDecimal taxRate) {
this.id = id;
this.items = items;
this.taxRate = taxRate;
}
public String id() { return id; }
public List items() { return items; }
public BigDecimal taxRate() { return taxRate; }
}
class InvoiceCalculator {
public BigDecimal totalBeforeTax(Invoice invoice) {
return invoice.items().stream()
.map(LineItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public BigDecimal tax(Invoice invoice) {
return totalBeforeTax(invoice).multiply(invoice.taxRate());
}
public BigDecimal totalWithTax(Invoice invoice) {
return totalBeforeTax(invoice).add(tax(invoice));
}
}
interface InvoiceRepository {
void save(Invoice invoice);
}
class InMemoryInvoiceRepository implements InvoiceRepository {
@Override
public void save(Invoice invoice) {
// Pretend we save to DB. SRP: this class owns persistence details.
System.out.println("Saved invoice " + invoice.id());
}
}
class InvoicePrinter {
public void print(Invoice invoice, InvoiceCalculator calculator) {
System.out.println("Invoice " + invoice.id());
invoice.items().forEach(it ->
System.out.println(" - " + it.description() + " -> " + it.subtotal())
);
System.out.println("Total: " + calculator.totalWithTax(invoice));
}
}
class InvoiceApp {
public static void main(String[] args) {
Invoice invoice = new Invoice(
"INV-001",
List.of(new LineItem("Book", 2, new BigDecimal("12.50")),
new LineItem("Pen", 5, new BigDecimal("1.20"))),
new BigDecimal("0.20")
);
InvoiceCalculator calculator = new InvoiceCalculator();
InvoiceRepository repo = new InMemoryInvoiceRepository();
InvoicePrinter printer = new InvoicePrinter();
repo.save(invoice);
printer.print(invoice, calculator);
}
}
Each class has a single reason to change:
- Calculator: tax logic changes
- Repository: storage changes
- Printer: output format changes
Open/Closed Principle (OCP)
Add behavior by adding code, not by modifying existing code in risky places.
What it means
You should be able to extend the system by adding new types without editing the core logic. Polymorphism and composition are your friends here.
Common smells
- Big switch/if statements on type or enum values scattered across the app.
- Adding a new "shape" requires changing multiple files.
A clean OCP example
Sum areas of shapes without changing the calculator every time we add a shape.
import java.util.List;
interface Shape {
double area();
}
class Circle implements Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override public double area() { return Math.PI * radius * radius; }
}
class Rectangle implements Shape {
private final double width, height;
public Rectangle(double width, double height) {
this.width = width; this.height = height;
}
@Override public double area() { return width * height; }
}
class AreaCalculator {
public double totalArea(List shapes) {
return shapes.stream().mapToDouble(Shape::area).sum();
}
}
class ShapesDemo {
public static void main(String[] args) {
List shapes = List.of(
new Circle(2.0),
new Rectangle(3.0, 4.0)
);
System.out.println(new AreaCalculator().totalArea(shapes));
// Add a new shape by adding a class, no change to AreaCalculator needed.
Shape triangle = new Shape() {
private final double base = 3.0, height = 5.0;
@Override public double area() { return 0.5 * base * height; }
};
System.out.println(new AreaCalculator().totalArea(List.of(triangle)));
}
}
Notice how adding a Triangle
doesn’t touch AreaCalculator
. The calculator is “closed” for modification but “open” for extension via new Shape
implementations.
Liskov Substitution Principle (LSP)
If it looks like a duck and quacks like a duck, substituting it shouldn’t break your code.
What it means
Anywhere you expect a base type, you should be able to use a subtype without surprising behavior. Violations often show up when subtypes throw UnsupportedOperationException or silently ignore contracts.
Common smells
- Subclasses override methods to throw exceptions for valid base-type calls.
- Preconditions get stricter or postconditions get weaker in subtypes.
A classic violation and a fix
The “Bird can fly” trap:
// Violation example
class Bird {
public void eat() { System.out.println("Peck peck"); }
public void fly() { System.out.println("Flap flap"); }
}
class Ostrich extends Bird {
@Override
public void fly() {
// Ostriches can't fly! What now?
throw new UnsupportedOperationException("I can't fly");
}
}
Code that accepts Bird
and calls fly()
will blow up with an Ostrich
. Instead, model capabilities with separate abstractions.
interface Animal {
void eat();
}
interface Flyable {
void fly();
}
class Sparrow implements Animal, Flyable {
@Override public void eat() { System.out.println("Sparrow eats seeds"); }
@Override public void fly() { System.out.println("Sparrow flying"); }
}
class Ostrich implements Animal {
@Override public void eat() { System.out.println("Ostrich eats plants"); }
}
class Aviary {
public void feedAll(Iterable animals) {
for (Animal a : animals) a.eat();
}
public void letThemFly(Iterable fliers) {
for (Flyable f : fliers) f.fly();
}
}
By segregating “fly” behavior, we can substitute any Flyable
safely where flying is expected.
Interface Segregation Principle (ISP)
Keep your interfaces small and focused so implementers don’t need to fake features.
What it means
Clients shouldn’t be forced to depend on methods they don’t use. Split “fat” interfaces into smaller ones.
Common smells
- Implementations with empty or throwing methods because “we don’t support that.”
- Growing interfaces that try to be everything to everyone.
A clean ISP example
interface Printer {
void print(String document);
}
interface ScannerDevice {
String scan();
}
interface Fax {
void fax(String number, String document);
}
class SimplePrinter implements Printer {
@Override public void print(String document) {
System.out.println("Printing: " + document);
}
}
class MultiFunctionMachine implements Printer, ScannerDevice, Fax {
@Override public void print(String document) { System.out.println("MFP Printing: " + document); }
@Override public String scan() { return "Scanned content"; }
@Override public void fax(String number, String document) {
System.out.println("Faxing to " + number + ": " + document);
}
}
// Client depends only on what it needs
class PrintService {
private final Printer printer;
public PrintService(Printer printer) { this.printer = printer; }
public void printReport(String content) {
printer.print(content);
}
}
Clients that only print depend on Printer
. No need to pull in scanning or fax code paths.
Dependency Inversion Principle (DIP)
High-level policy depends on abstractions; low-level details plug in behind interfaces.
What it means
Your core business logic should not depend on database drivers, file systems, or HTTP libraries. Flip the direction: define interfaces in the high-level module and implement them in lower-level modules.
Common smells
- Services directly
new
a database client or file writer. - Hard-to-test code because mocking is painful or impossible.
- Business logic scattered with I/O concerns.
A clean DIP example with constructor injection
import java.time.Instant;
import java.util.UUID;
class Report {
private final String id;
private final String content;
private final Instant createdAt;
public Report(String content) {
this.id = UUID.randomUUID().toString();
this.content = content;
this.createdAt = Instant.now();
}
public String id() { return id; }
public String content() { return content; }
public Instant createdAt() { return createdAt; }
}
// Abstraction owned by high-level policy
interface ReportRepository {
void save(Report report);
Report findById(String id);
}
// Low-level details implement the abstraction
class FileReportRepository implements ReportRepository {
@Override
public void save(Report report) {
// Real code would write to disk; simplified for demo
System.out.println("Saving to file: " + report.id());
}
@Override
public Report findById(String id) {
// Simplified stub
return new Report("Loaded content for " + id);
}
}
class ReportService {
private final ReportRepository repository;
// Constructor injection
public ReportService(ReportRepository repository) {
this.repository = repository;
}
public Report createAndSave(String content) {
Report report = new Report(content);
repository.save(report);
return report;
}
public Report get(String id) {
return repository.findById(id);
}
}
class ReportApp {
public static void main(String[] args) {
ReportRepository repo = new FileReportRepository(); // could be swapped for a DB impl
ReportService service = new ReportService(repo);
Report r = service.createAndSave("Quarterly numbers");
System.out.println(service.get(r.id()).content());
}
}
This setup allows you to:
- Swap implementations (e.g.,
DbReportRepository
,InMemoryReportRepository
) without changingReportService
. - Unit test
ReportService
by passing a fake/stub repository.
Pro tip: Frameworks like Spring make constructor injection and wiring up dependencies super straightforward.
Putting It All Together
When you design a feature, you can apply SOLID like this:
- SRP: Split concerns: calculation vs. persistence vs. presentation.
- OCP: Use interfaces/abstract classes so new behaviors don’t change old code.
- LSP: Ensure subtypes honor the base contract; prefer composition over inheritance when unsure.
- ISP: Let clients depend only on what they actually need.
- DIP: Invert dependencies so business logic owns the abstractions.
A small checklist you can keep handy
- Are any classes doing more than one job? Split them. (SRP)
- Do I need to modify an existing class to add a variant? If yes, rethink the abstraction. (OCP)
- Can my subtype replace the base type without weird exceptions? (LSP)
- Does a client interface have methods some clients don’t use? Split it. (ISP)
- Does my core logic directly instantiate low-level details? Inject interfaces. (DIP)
Common Pitfalls and How to Avoid Them
- Overengineering too early: Premature abstraction can make code harder, not easier. Start simple; refactor toward SOLID when you feel strain.
- Abuse of inheritance: Prefer composition. Inheritance can quickly violate LSP; interfaces plus composition are often safer.
- Cargo-culting patterns: Don’t introduce factories, strategies, and six layers “just because.” Keep it practical.
- Skipping tests: SOLID and unit tests reinforce each other. If it’s hard to test, that’s feedback your design might be too coupled.
Rule of thumb: If a change requires touching multiple unrelated classes, you may be missing SRP/OCP. If testing requires spinning up databases or networks, you may be missing DIP.
Further Reading
- SOLID overview on Wikipedia
- “Clean Architecture” by Robert C. Martin: book site
- “Effective Java” by Joshua Bloch: book
- Spring Framework dependency injection guide: spring.io
Final Thoughts
SOLID won’t write the code for you, but it will nudge you toward designs that age gracefully. Keep the principles in your back pocket, apply them when your code starts feeling sticky, and iterate. Your teammates—and future you—will thank you.
Happy coding!