- Understand and apply the Single Responsibility Principle
- Master the Open/Closed and Liskov Substitution Principles
- Learn Interface Segregation and Dependency Inversion
SOLID Principles in Java
You can write code that works. But can you write code that's easy to change, test, and extend? That's harder. As projects grow, poor design makes every change risky.
SOLID principles are five guidelines for maintainable, flexible code. Robert C. Martin (Uncle Bob) introduced them. They're foundational to OOP.
The Five SOLID Principles
| Letter | Principle | One-Line Summary |
|---|---|---|
| S | Single Responsibility | A class should have only one reason to change |
| O | Open/Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be substitutable for their base types |
| I | Interface Segregation | Many specific interfaces are better than one general-purpose interface |
| D | Dependency Inversion | Depend on abstractions, not concrete implementations |
Single Responsibility Principle (SRP)
"A class should have only one reason to change."
The Problem
When a class does too many things, it becomes fragile. A change in one area might break another. Consider this common anti-pattern:
// BAD: This class has too many responsibilities
public class Employee {
private String name;
private double salary;
// Responsibility 1: Employee data
public String getName() { return name; }
public double getSalary() { return salary; }
// Responsibility 2: Salary calculations
public double calculateAnnualSalary() {
return salary * 12;
}
// Responsibility 3: Database operations
public void save() {
// SQL to save employee
}
// Responsibility 4: Reporting
public String generateReport() {
return "Employee Report: " + name + " - $" + salary;
}
// Responsibility 5: Email notifications
public void sendPayslip() {
// Email sending logic
}
}
If the database schema changes, you modify this class. If the report format changes, you modify this class. If the email system changes, you modify this class. Every change risks breaking something unrelated.
The Solution
Split the class by responsibility:
// Class 1: Just employee data
public class Employee {
private String name;
private String email;
private double salary;
public Employee(String name, String email, double salary) {
this.name = name;
this.email = email;
this.salary = salary;
}
// Only getters and setters - just data
public String getName() { return name; }
public String getEmail() { return email; }
public double getSalary() { return salary; }
}
// Class 2: Salary calculations
public class SalaryCalculator {
public double calculateAnnualSalary(Employee employee) {
return employee.getSalary() * 12;
}
public double calculateBonus(Employee employee, double percentage) {
return employee.getSalary() * percentage;
}
}
// Class 3: Persistence
public class EmployeeRepository {
public void save(Employee employee) {
// Database logic here
}
public Employee findById(Long id) {
// Database query here
return null;
}
}
// Class 4: Reporting
public class EmployeeReportGenerator {
public String generate(Employee employee) {
return String.format("Employee: %s, Salary: $%.2f",
employee.getName(), employee.getSalary());
}
}
// Class 5: Notifications
public class PayslipNotifier {
public void sendPayslip(Employee employee) {
// Email logic here
}
}
Now each class has one reason to change. Database changes? Only modify EmployeeRepository. Report format changes? Only modify EmployeeReportGenerator.
Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
The Problem
When you need to add new behavior, you shouldn't have to modify existing code. Modification risks introducing bugs in working code.
// BAD: Adding a new shape requires modifying this class
public class AreaCalculator {
public double calculate(Object shape) {
if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.width * r.height;
} else if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius * c.radius;
}
// Adding Triangle? Must modify this method!
throw new IllegalArgumentException("Unknown shape");
}
}
The Solution
Use abstraction and polymorphism:
// Define what shapes must do
public interface Shape {
double area();
}
// Each shape implements its own area calculation
public class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
public class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
// Calculator works with any shape - no modification needed for new shapes
public class AreaCalculator {
public double calculate(Shape shape) {
return shape.area();
}
public double calculateTotal(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::area)
.sum();
}
}
// Adding Triangle - no changes to existing code!
public class Triangle implements Shape {
private double base;
private double height;
@Override
public double area() {
return 0.5 * base * height;
}
}
Now the system is open (you can add new shapes) but closed (you don't modify existing code).
Liskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of its subclasses without affecting program correctness."
The Problem
Inheritance can be misused when a subclass doesn't truly represent an "is-a" relationship:
// Classic example: Square "is-a" Rectangle... or is it?
public class Rectangle {
protected double width;
protected double height;
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double getArea() {
return width * height;
}
}
public class Square extends Rectangle {
// Square must have equal sides, so override setters
@Override
public void setWidth(double width) {
this.width = width;
this.height = width; // Keep it square
}
@Override
public void setHeight(double height) {
this.width = height; // Keep it square
this.height = height;
}
}
This breaks LSP! Watch what happens:
public void testRectangle(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(10);
assert rect.getArea() == 50; // Works for Rectangle, FAILS for Square!
}
The Solution
Don't force inheritance when it doesn't fit. Use a common interface:
public interface Shape {
double getArea();
}
public class Rectangle implements Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double getArea() {
return width * height;
}
}
public class Square implements Shape {
private final double side;
public Square(double side) {
this.side = side;
}
@Override
public double getArea() {
return side * side;
}
}
Now both can be used as Shape without surprises. The key insight: inheritance should model behavior compatibility, not just attribute similarity.
Interface Segregation Principle (ISP)
"No client should be forced to depend on methods it does not use."
The Problem
A "fat" interface forces implementations to provide methods they don't need:
// BAD: One interface trying to cover all workers
public interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
}
// Human worker - all methods make sense
public class HumanWorker implements Worker {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
public void attendMeeting() { /* ... */ }
}
// Robot worker - eat and sleep don't apply!
public class RobotWorker implements Worker {
public void work() { /* ... */ }
public void eat() { /* What should this do?! */ }
public void sleep() { /* What should this do?! */ }
public void attendMeeting() { /* ... */ }
}
The Solution
Split into focused interfaces:
// Focused interfaces
public interface Workable {
void work();
}
public interface Feedable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface MeetingAttendee {
void attendMeeting();
}
// Human implements all relevant interfaces
public class HumanWorker implements Workable, Feedable, Sleepable, MeetingAttendee {
public void work() { /* ... */ }
public void eat() { /* ... */ }
public void sleep() { /* ... */ }
public void attendMeeting() { /* ... */ }
}
// Robot only implements what makes sense
public class RobotWorker implements Workable, MeetingAttendee {
public void work() { /* ... */ }
public void attendMeeting() { /* ... */ }
}
Each class implements only the interfaces it needs. No empty methods, no awkward workarounds.
Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
The Problem
When high-level code depends directly on low-level implementations, changes cascade:
// BAD: OrderProcessor depends directly on MySQLDatabase
public class MySQLDatabase {
public void save(Order order) {
// MySQL-specific code
}
}
public class OrderProcessor {
private MySQLDatabase database = new MySQLDatabase(); // Tight coupling!
public void process(Order order) {
// Process order...
database.save(order);
}
}
// Problems:
// - Can't easily switch to PostgreSQL or MongoDB
// - Can't test OrderProcessor without a real database
// - Changes to MySQLDatabase might break OrderProcessor
The Solution
Depend on abstractions (interfaces), not implementations:
// Define an abstraction
public interface OrderRepository {
void save(Order order);
Order findById(String id);
}
// Low-level module implements the abstraction
public class MySQLOrderRepository implements OrderRepository {
@Override
public void save(Order order) {
// MySQL-specific code
}
@Override
public Order findById(String id) {
// MySQL query
return null;
}
}
// High-level module depends on abstraction
public class OrderProcessor {
private final OrderRepository repository; // Depends on interface!
// Dependency injected through constructor
public OrderProcessor(OrderRepository repository) {
this.repository = repository;
}
public void process(Order order) {
// Process order...
repository.save(order);
}
}
// Now we can easily:
// 1. Switch implementations
OrderProcessor processor1 = new OrderProcessor(new MySQLOrderRepository());
OrderProcessor processor2 = new OrderProcessor(new MongoOrderRepository());
// 2. Test with mocks
OrderProcessor testProcessor = new OrderProcessor(new MockOrderRepository());
This is the foundation of dependency injection, used by frameworks like Spring.
Applying SOLID in Practice
Start Simple
Don't over-engineer from the start. Apply SOLID when:
- A class is getting too large (SRP)
- You're using many if/else for types (OCP)
- Subclasses behave unexpectedly (LSP)
- Interfaces are too large (ISP)
- Code is hard to test (DIP)
Recognize the Signs
| Smell | Likely Violated Principle |
|---|---|
| Class has many unrelated methods | SRP |
| Adding features requires modifying existing code | OCP |
| Subclass methods throw NotImplementedException | LSP or ISP |
| Classes implement empty methods | ISP |
| Hard to unit test in isolation | DIP |
SOLID principles are guidelines, not strict rules. They work together—violating one often leads to violating others. Apply them when you see code smells, not preemptively. The goal is maintainability—if a principle makes code harder to understand, reconsider. Testing gets easier with SOLID. Modern frameworks (Spring, etc.) are built on these principles.
