SOLID Principles in Java: Complete Guide with Examples
Learn SOLID principles in Java with simple explanations and practical examples. This guide covers Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles with before-and-after code for beginners and experts.
Why SOLID Principles Still Matter in Java
Writing code that works is easy. Writing code that survives change is the real challenge.
Robert C. Martin introduced the SOLID principles to combat "software rot"—the tendency of codebases to become rigid, fragile, and difficult to maintain as they grow. These five principles provide a blueprint for building software that is:
- Understandable — New developers can navigate the codebase faster
- Flexible — Requirements change without breaking everything
- Maintainable — Fixes and updates stay localized
- Testable — Units can be verified in isolation
Whether you're preparing for interviews or architecting enterprise systems, SOLID isn't academic theory—it's practical wisdom distilled from decades of building software.
S — Single Responsibility Principle (SRP)
"A class should have one, and only one, reason to change."
What It Really Means
SRP isn't about having one method per class. It's about one reason to change. If your class serves two different stakeholders (e.g., accounting and HR), a change requested by one can break functionality for the other.
Violation: The Multi-Purpose Invoice
public class Invoice {
private double amount;
public double calculateTotal() {
return amount * 1.18; // Business logic
}
public void saveToDatabase() {
// Persistence logic
System.out.println("Saving invoice...");
}
public void printReport() {
// Presentation logic
System.out.println("Printing report...");
}
}
The Problem: Three reasons to change. Tax laws change → modify
calculateTotal(). Database schema changes → modify saveToDatabase().
Report format changes → modify printReport(). One class, three stakeholders,
endless fragility.
Solution: Separate the Concerns
class Invoice {
private double amount;
private double taxRate;
public double calculateTotal() {
return amount * (1 + taxRate);
}
}
class InvoiceRepository {
public void save(Invoice invoice) {
// Only persistence logic lives here
}
}
class InvoiceReportGenerator {
public String generateReport(Invoice invoice) {
return "Invoice total: " + invoice.calculateTotal();
}
}
For Beginners: Think of SRP like a kitchen. The chef cooks. The dishwasher cleans. The server delivers. When one person does everything, mistakes happen.
For Experts: SRP aligns with bounded contexts in Domain-Driven Design. Each class becomes a natural seam for testing, mocking, and independent deployment.
O — Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
What It Really Means
You should be able to add new behavior without editing existing, tested code. The primary mechanism? Polymorphism through interfaces or abstract classes.
Violation: The Growing If-Else
public class NotificationService {
public void sendNotification(String type, String message) {
if (type.equals("EMAIL")) {
System.out.println("Email: " + message);
} else if (type.equals("SMS")) {
System.out.println("SMS: " + message);
}
// Every new type means editing this method again
}
}
The Problem: Every new notification channel (Push, Slack, WhatsApp) requires
modifying sendNotification(). You risk breaking existing, working code with each change.
Solution: Strategy Pattern via Interfaces
interface NotificationChannel {
void send(String message);
}
class EmailChannel implements NotificationChannel {
public void send(String message) {
System.out.println("Email: " + message);
}
}
class SmsChannel implements NotificationChannel {
public void send(String message) {
System.out.println("SMS: " + message);
}
}
class NotificationService {
private final NotificationChannel channel;
public NotificationService(NotificationChannel channel) {
this.channel = channel;
}
public void notify(String message) {
channel.send(message);
}
}
Now adding PushChannel means writing one new class, not editing
the service. The service is closed for modification but open for extension.
For Beginners: Imagine a power strip. You don't rewire the strip to add a new device—you just plug it in. Interfaces are the sockets.
For Experts: This is the foundation of the Strategy pattern. In Spring
applications, this translates to autowired List<NotificationChannel> where
the framework handles extension discovery.
L — Liskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."
What It Really Means
If your code works with a Bird, it should work with a Penguin—without
surprises. Subclasses must honor the contract of their parent.
Violation: The Rectangle-Square Trap
class Rectangle {
protected int width;
protected int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
class Square extends Rectangle {
@Override
public void setWidth(int w) {
this.width = w;
this.height = w; // Forces height to match!
}
@Override
public void setHeight(int h) {
this.width = h;
this.height = h;
}
}
// Client code expects Rectangle behavior
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // FAILS for Square: returns 16
}
The Problem: A Square cannot substitute a Rectangle
without breaking the caller's expectations. The subclass silently altered behavior.
Solution: Separate Abstractions
interface Shape {
int area();
}
class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int w, int h) {
this.width = w;
this.height = h;
}
public int area() { return width * height; }
}
class Square implements Shape {
private final int side;
public Square(int side) {
this.side = side;
}
public int area() { return side * side; }
}
Neither class inherits problematic behavior from the other. Both can be used wherever a
Shape is expected.
For Beginners: Don't force a round peg into a square hole—even if the peg's class extends the hole's class.
For Experts: LSP violations often manifest as instanceof checks
or UnsupportedOperationException throws. These are code smells signaling an
incorrect inheritance hierarchy.
I — Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they do not use."
What It Really Means
Fat interfaces create forced dependencies. Splitting them into focused, role-based interfaces keeps code clean and prevents meaningless implementations.
Violation: The Fat Worker Interface
interface Worker {
void work();
void eat();
void sleep();
}
class RobotWorker implements Worker {
public void work() { System.out.println("Working..."); }
public void eat() { throw new UnsupportedOperationException("Robots don't eat!"); }
public void sleep() { throw new UnsupportedOperationException("Robots don't sleep!"); }
}
The Problem: RobotWorker is forced to implement methods that
make no sense for it. The interface is polluted.
Solution: Focused Interfaces
interface Workable {
void work();
}
interface Feedable {
void eat();
}
interface Sleepable {
void sleep();
}
class HumanWorker implements Workable, Feedable, Sleepable {
public void work() { System.out.println("Working..."); }
public void eat() { System.out.println("Eating..."); }
public void sleep() { System.out.println("Sleeping..."); }
}
class RobotWorker implements Workable {
public void work() { System.out.println("Working..."); }
}
Each class implements only what it actually needs. No meaningless methods, no exceptions thrown for methods that should never exist.
For Beginners: Don't sign a contract for services you'll never use. Small, focused interfaces keep everyone honest.
For Experts: ISP reduces the ripple effect of interface changes. A change
to Feedable only affects classes that actually feed—not every worker in the system.
D — Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
What It Really Means
Your business logic (high-level) shouldn't be coupled to specific databases, APIs, or frameworks (low-level). Both should depend on interfaces you control.
Violation: Hard-Wired Dependencies
class MySQLDatabase {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}
class OrderService {
private MySQLDatabase database = new MySQLDatabase(); // Tight coupling!
public void placeOrder(String order) {
database.save(order);
}
}
The Problem: OrderService is permanently married to
MySQLDatabase. Switching to PostgreSQL means editing the service. Testing
requires a real database.
Solution: Depend on Abstractions
interface Database {
void save(String data);
}
class MySQLDatabase implements Database {
public void save(String data) {
System.out.println("Saving to MySQL: " + data);
}
}
class PostgreSQLDatabase implements Database {
public void save(String data) {
System.out.println("Saving to PostgreSQL: " + data);
}
}
class OrderService {
private final Database database;
public OrderService(Database database) { // Injected, not created
this.database = database;
}
public void placeOrder(String order) {
database.save(order);
}
}
Now OrderService depends on the Database abstraction. You can
inject MySQL, PostgreSQL, or a mock for testing—without changing a single line of business logic.
For Beginners: Instead of soldering your lamp directly to the wall, plug it into a socket. You can change lamps without rewiring your house.
For Experts: This is the philosophical foundation of Spring's dependency
injection. @Autowired is DIP in action. The framework becomes the "injector"
that wires abstractions to implementations at runtime.
Putting It All Together: The Insurance Claim Example
The best way to understand SOLID is to see all five principles applied to one system.
System: Insurance claim processing for AUTO, HEALTH, and PROPERTY claims.
| Principle | Application |
|---|---|
| SRP | ClaimValidator validates. ClaimService orchestrates. ClaimController handles HTTP. |
| OCP | Adding TRAVEL claim type? Create TravelClaimProcessor. No existing code changes. |
| LSP | All processors implement ClaimProcessor. Any can replace another in the pipeline. |
| ISP | ClaimProcessor has one method: process(Claim). No fat interfaces. |
| DIP | ClaimService depends on ClaimProcessor interface, injected via factory. |
This architecture scales. New claim types, new validation rules, new notification channels—all added without touching the core.
Frequently Asked Questions
What are SOLID principles in Java?
SOLID is an acronym for five object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. They help developers create maintainable, flexible, and testable code.
Why should I learn SOLID as a beginner?
SOLID principles prevent you from learning bad habits. Code that violates SOLID becomes painful to change. Learning these principles early means writing code that's easier to debug, extend, and explain in interviews.
Do SOLID principles apply to Spring Boot?
Absolutely. Spring's dependency injection is DIP in practice. Spring Data repositories follow ISP. The framework encourages SOLID-aligned architecture.
Is SOLID always the answer?
No. SOLID principles are tools, not dogma. Over-applying them can lead to excessive abstraction and complex code. Use judgment. For small scripts or prototypes, strict SOLID might be overkill.
How do SOLID principles relate to design patterns?
SOLID principles are the why. Design patterns (Strategy, Factory, Observer) are the how. OCP leads to Strategy. DIP leads to dependency injection. SRP leads to separation of concerns.
Your SOLID Checklist
Before committing code, ask:
- SRP: Does this class have more than one reason to change?
- OCP: Will adding new behavior require editing existing, tested code?
- LSP: Can every subclass replace its parent without surprises?
- ISP: Is any class forced to implement methods it doesn't need?
- DIP: Does high-level logic depend on concrete implementations?
Start small. Pick one principle. Refactor one class. The habit compounds.
Conclusion
SOLID principles aren't about perfection—they're about conscious design. They won't eliminate every bug or predict every future requirement. But they will make your codebase more resilient, readable, and ready for the changes that will come.
Master them not as rules, but as tools. Know when to apply them, when to relax them, and when they save your project from becoming the legacy nightmare everyone dreads.
Related topics: Java Stream API, Java Lambda Expressions, Java Optional Class, Java Functional Interfaces, Spring Boot Dependency Injection