The SOLID DDesign principles are five key design principles that help developers create maintainable, extensible, and flexible object-oriented code. These principles were introduced by Robert C. Martin and have since become the cornerstone of clean code practices.
Table of Contents
Here’s a breakdown of SOLID principles with examples in Java:
Single Responsibility Principle (SRP)
A class should have only one reason to change, meaning it should have only one job or responsibility.
Problem:
If a class handles more than one responsibility, changes in one part might affect other parts. This increases the risk of introducing bugs when modifying the code.
Example:
// Bad example: Class violates SRP
class UserService {
public void registerUser(String username, String password) {
// Logic to register the user
}
public void sendWelcomeEmail(String email) {
// Logic to send email
}
}
In the above example, UserService
handles both user registration and email sending. These are two different responsibilities.
Solution:
We can refactor this into two classes, each having a single responsibility:
// Good example: SRP applied
class UserService {
public void registerUser(String username, String password) {
// Logic to register the user
}
}
class EmailService {
public void sendWelcomeEmail(String email) {
// Logic to send email
}
}
Now, the UserService
is only responsible for user registration, and the EmailService
is responsible for sending emails.
Open/Closed Principle (OCP)
Software entities (classes, modules, functions) should be open for extension, but closed for modification. This means that you can extend a class’s behavior without modifying its existing code.
Problem:
If we modify the existing code to add new functionality, we might introduce bugs or affect other parts of the system.
Example:
// Bad example: Violates OCP
class AreaCalculator {
public double calculateRectangleArea(Rectangle rectangle) {
return rectangle.length * rectangle.width;
}
public double calculateCircleArea(Circle circle) {
return Math.PI * circle.radius * circle.radius;
}
}
If you need to add a new shape (e.g., triangle), you would need to modify the AreaCalculator
class.
Solution:
Using inheritance and polymorphism, we can extend the class without modifying the existing code:
// Good example: OCP applied
interface Shape {
double calculateArea();
}
class Rectangle implements Shape {
double length, width;
public double calculateArea() {
return length * width;
}
}
class Circle implements Shape {
double radius;
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class AreaCalculator {
public double calculateArea(Shape shape) {
return shape.calculateArea();
}
}
Here, we can add new shapes like Triangle
by implementing the Shape
interface, without changing the AreaCalculator
class.
Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
Problem:
Violating LSP leads to unexpected behaviors when subclass instances are used in place of superclass instances.
Example:
// Bad example: Violates LSP
class Bird {
public void fly() {
System.out.println("Flying...");
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
A Penguin
is a Bird
, but penguins can’t fly. This violates LSP because a subclass should behave like the superclass.
Solution:
Refactor the design to follow LSP:
// Good example: LSP applied
class Bird {
public void move() {
System.out.println("Moving...");
}
}
class FlyingBird extends Bird {
public void fly() {
System.out.println("Flying...");
}
}
class Penguin extends Bird {
@Override
public void move() {
System.out.println("Swimming...");
}
}
Now, Penguin
can be substituted as a Bird
, and it doesn’t break the logic by attempting to fly.
Interface Segregation Principle (ISP)
Clients should not be forced to implement interfaces they do not use. Instead, break down interfaces into smaller, more specific ones.
Problem:
If an interface has too many methods, a class implementing it might be forced to define methods it doesn’t need.
Example:
// Bad example: Violates ISP
interface Worker {
void work();
void eat();
}
class HumanWorker implements Worker {
public void work() {
// Human working
}
public void eat() {
// Human eating
}
}
class RobotWorker implements Worker {
public void work() {
// Robot working
}
public void eat() {
// Robot doesn't eat, but must implement the method
}
}
RobotWorker
is forced to implement eat()
even though it doesn’t need it.
Solution:
Split the interface into smaller, more specific ones:
// Good example: ISP applied
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class HumanWorker implements Workable, Eatable {
public void work() {
// Human working
}
public void eat() {
// Human eating
}
}
class RobotWorker implements Workable {
public void work() {
// Robot working
}
}
Now, RobotWorker
only implements the Workable
interface, and HumanWorker
implements both Workable
and Eatable
.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces). Furthermore, abstractions should not depend on details. Details should depend on abstractions.
Problem:
If high-level modules depend on low-level modules, changes in low-level components may affect high-level components.
Example:
// Bad example: Violates DIP
class Keyboard {
// Keyboard details
}
class Computer {
private Keyboard keyboard;
public Computer() {
this.keyboard = new Keyboard(); // Direct dependency on low-level module
}
}
The Computer
class is directly dependent on the Keyboard
class, making it hard to extend.
Solution:
Introduce an abstraction and have both high-level and low-level modules depend on it:
// Good example: DIP applied
interface InputDevice {
void input();
}
class Keyboard implements InputDevice {
public void input() {
// Keyboard input logic
}
}
class Computer {
private InputDevice inputDevice;
public Computer(InputDevice inputDevice) {
this.inputDevice = inputDevice;
}
}
Now, the Computer
depends on the InputDevice
abstraction, allowing us to switch to other input devices (e.g., mouse) without modifying the Computer
class.
Conclusion:
By adhering to the SOLID principles, developers can produce easy code to maintain, extend, and refactor. These principles encourage clear separation of concerns, minimize the risk of introducing bugs during code changes, and make the system more flexible.
Each principle focuses on a different aspect of object-oriented design and collectively, they form the foundation of clean, robust software architecture.
1 thought on “SOLID Design Principles in Java: A Comprehensive Guide with Examples”