SOLID is an acronym representing five fundamental principles of object-oriented programming and design. When applied properly, these principles lead to software that is more understandable, flexible, maintainable, and testable. For Java developers, understanding SOLID is crucial for writing clean, professional-grade code that can stand the test of time.
This article explores each SOLID principle with clear Java examples, demonstrating both violations and corrections.
S - Single Responsibility Principle (SRP)
"A class should have one, and only one, reason to change."
In other words, a class should have only one job or responsibility.
Violation Example:
public class Employee {
private String name;
private String position;
private double salary;
// Constructor, getters, setters...
// Business logic - should this be here?
public double calculatePay() {
// Complex salary calculation logic
return salary * 1.1;
}
// Data persistence - should this be here?
public void saveToDatabase() {
// Database saving logic
System.out.println("Saving " + name + " to database...");
}
// Reporting - should this be here?
public void generateReport() {
// Report generation logic
System.out.println("Generating report for " + name);
}
}
This class has multiple reasons to change: if business rules change, if database schema changes, or if report format changes.
Corrected Example:
// Responsible only for representing employee data
public class Employee {
private String name;
private String position;
private double salary;
// Constructor, getters, setters only
public Employee(String name, String position, double salary) {
this.name = name;
this.position = position;
this.salary = salary;
}
public String getName() { return name; }
public String getPosition() { return position; }
public double getSalary() { return salary; }
}
// Responsible for payment calculations
public class PayCalculator {
public double calculatePay(Employee employee) {
return employee.getSalary() * 1.1;
}
}
// Responsible for data persistence
public class EmployeeRepository {
public void save(Employee employee) {
System.out.println("Saving " + employee.getName() + " to database...");
}
}
// Responsible for reporting
public class ReportGenerator {
public void generateReport(Employee employee) {
System.out.println("Generating report for " + employee.getName());
}
}
O - Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
You should be able to add new functionality without changing existing code.
Violation Example:
public class AreaCalculator {
public double calculateArea(Object shape) {
if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
return rect.getWidth() * rect.getHeight();
} else if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.getRadius() * circle.getRadius();
}
// Adding a new shape requires modifying this method
throw new IllegalArgumentException("Unknown shape type");
}
}
Corrected Example:
// Abstract base class or interface
public interface Shape {
double calculateArea();
}
// Concrete implementations
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 calculateArea() {
return width * height;
}
}
public class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// Area calculator doesn't need to change when new shapes are added
public class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
// New shape can be added without modifying existing code
public class Triangle implements Shape {
private double base;
private double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
L - Liskov Substitution Principle (LSP)
"Subtypes must be substitutable for their base types without altering the correctness of the program."
A subclass should enhance, not break, the functionality of its parent class.
Violation Example:
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width); // Violates rectangle behavior
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height); // Violates rectangle behavior
}
}
// This breaks the expected behavior
public class Demo {
public static void main(String[] args) {
Rectangle rectangle = new Square();
rectangle.setWidth(5);
rectangle.setHeight(10);
// Expected area: 5 * 10 = 50
// Actual area: 10 * 10 = 100 ❸
System.out.println("Area: " + rectangle.getArea());
}
}
Corrected Example:
// Common interface
public interface Shape {
int getArea();
}
// Separate implementations without inheritance
public class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
@Override
public int getArea() {
return width * height;
}
}
public class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
public void setSide(int side) { this.side = side; }
@Override
public int getArea() {
return side * side;
}
}
I - Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they do not use."
Create focused, specific interfaces rather than general-purpose ones.
Violation Example:
// "Fat" interface forcing implementors to provide empty implementations
public interface Worker {
void work();
void eat();
void sleep();
void code();
void design();
}
public class Programmer implements Worker {
@Override
public void work() { System.out.println("Programming..."); }
@Override
public void eat() { System.out.println("Eating while coding..."); }
@Override
public void sleep() { System.out.println("Dreaming about code..."); }
@Override
public void code() { System.out.println("Writing Java code..."); }
@Override
public void design() {
// Programmer doesn't design - forced empty implementation!
throw new UnsupportedOperationException("Programmers don't design!");
}
}
Corrected Example:
// Segregated interfaces
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public interface Codable {
void code();
}
public interface Designable {
void design();
}
// Classes implement only what they need
public class Programmer implements Workable, Eatable, Sleepable, Codable {
@Override
public void work() { System.out.println("Programming..."); }
@Override
public void eat() { System.out.println("Eating while coding..."); }
@Override
public void sleep() { System.out.println("Dreaming about code..."); }
@Override
public void code() { System.out.println("Writing Java code..."); }
}
public class Designer implements Workable, Eatable, Sleepable, Designable {
@Override
public void work() { System.out.println("Designing..."); }
@Override
public void eat() { System.out.println("Eating while designing..."); }
@Override
public void sleep() { System.out.println("Dreaming about designs..."); }
@Override
public void design() { System.out.println("Creating UI designs..."); }
}
D - Dependency Inversion Principle (DIP)
"Depend upon abstractions, not concretions."
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Violation Example:
// High-level module depending on low-level module
public class WeatherTracker {
private EmailNotifier emailNotifier; // Concrete dependency
private SMSNotifier smsNotifier; // Concrete dependency
public WeatherTracker() {
this.emailNotifier = new EmailNotifier();
this.smsNotifier = new SMSNotifier();
}
public void setConditions(String weather) {
if (weather.equals("rainy")) {
emailNotifier.sendAlert("It's rainy!");
} else if (weather.equals("sunny")) {
smsNotifier.sendAlert("It's sunny!");
}
}
}
// Low-level modules
public class EmailNotifier {
public void sendAlert(String message) {
System.out.println("Email: " + message);
}
}
public class SMSNotifier {
public void sendAlert(String message) {
System.out.println("SMS: " + message);
}
}
Corrected Example:
// Abstraction that both high and low levels depend on
public interface Notifier {
void sendAlert(String message);
}
// Low-level modules implement the abstraction
public class EmailNotifier implements Notifier {
@Override
public void sendAlert(String message) {
System.out.println("Email: " + message);
}
}
public class SMSNotifier implements Notifier {
@Override
public void sendAlert(String message) {
System.out.println("SMS: " + message);
}
}
// New notifier can be added without changing WeatherTracker
public class PushNotifier implements Notifier {
@Override
public void sendAlert(String message) {
System.out.println("Push Notification: " + message);
}
}
// High-level module depends on abstraction, not concretions
public class WeatherTracker {
private List<Notifier> notifiers;
// Dependency injection through constructor
public WeatherTracker(List<Notifier> notifiers) {
this.notifiers = notifiers;
}
public void setConditions(String weather) {
String message = "Current weather: " + weather;
for (Notifier notifier : notifiers) {
notifier.sendAlert(message);
}
}
}
// Usage with Dependency Injection
public class Demo {
public static void main(String[] args) {
List<Notifier> notifiers = Arrays.asList(
new EmailNotifier(),
new SMSNotifier(),
new PushNotifier() // Easy to extend
);
WeatherTracker tracker = new WeatherTracker(notifiers);
tracker.setConditions("sunny");
}
}
Benefits of Applying SOLID Principles
- Maintainability: Changes are localized and have minimal ripple effects
- Testability: Small, focused classes are easier to unit test
- Flexibility: New features can be added with minimal changes to existing code
- Reusability: Small, focused components can be reused across the system
- Reduced Coupling: Components are loosely connected, making the system more resilient to change
When to Apply SOLID
- For complex, long-lived projects where maintainability is crucial
- When working in teams to ensure consistent, understandable code
- For frameworks and libraries that others will extend
- When you anticipate future changes and want to minimize technical debt
Conclusion
SOLID principles provide a robust foundation for writing high-quality, maintainable Java code. While they might seem abstract at first, their practical application leads to software that is easier to understand, modify, and extend. Remember that these are guidelines, not strict rules—apply them judiciously based on your specific context. Over-engineering can be as problematic as under-engineering. The goal is to find the right balance that makes your code clean, flexible, and professional.