Understanding Core Java SOLID Principles with Real-Time Examples
11/28/20235 min read


Introduction
In object-oriented programming, SOLID is an acronym for five principles that help developers design software that is easy to maintain, understand, and extend. These principles, when applied correctly, result in code that is more modular, flexible, and robust. In this article, we will explore each of the SOLID principles and provide real-time examples in Java to illustrate their importance and application.
SOLID Principles Overview
The SOLID principles were introduced by Robert C. Martin, also known as Uncle Bob. They are:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. In other words, a class should have only one responsibility or job. This principle helps in keeping classes focused and makes them easier to understand, test, and maintain.
Let's consider an example:
public class Employee {
private String name;
private String id;
private double salary;
public void calculateSalary() {
// Calculate the salary based on some complex logic
}
public void saveEmployeeDetails() {
// Save the employee details to a database
}
public void sendNotification() {
// Send a notification to the employee
}
}
In the above code, the Employee
class violates the SRP by having multiple responsibilities. It is responsible for calculating the salary, saving employee details to a database, and sending notifications. To adhere to the SRP, we can refactor the code as follows:
public class Employee {
private String name;
private String id;
private double salary;
public void calculateSalary() {
// Calculate the salary based on some complex logic
}
}
public class EmployeeRepository {
public void saveEmployeeDetails(Employee employee) {
// Save the employee details to a database
}
}
public class NotificationService {
public void sendNotification(Employee employee) {
// Send a notification to the employee
}
}
By separating the responsibilities into different classes, we achieve a cleaner and more maintainable codebase.
Open/Closed Principle (OCP)
The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to add new functionality without modifying existing code.
Consider the following example:
public class Shape {
private String type;
public Shape(String type) {
this.type = type;
}
public void draw() {
if (type.equals("circle")) {
drawCircle();
} else if (type.equals("rectangle")) {
drawRectangle();
}
}
private void drawCircle() {
// Draw a circle
}
private void drawRectangle() {
// Draw a rectangle
}
}
In the above code, the Shape
class violates the OCP because every time we want to add a new shape, we need to modify the draw()
method. To adhere to the OCP, we can refactor the code using inheritance and polymorphism:
public abstract class Shape {
public abstract void draw();
}
public class Circle extends Shape {
public void draw() {
// Draw a circle
}
}
public class Rectangle extends Shape {
public void draw() {
// Draw a rectangle
}
}
With this refactored code, we can easily add new shapes by creating new classes that extend the Shape
class without modifying the existing code.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, a subclass should be able to be used wherever its superclass is expected.
Let's consider an example:
public class Rectangle {
private int width;
private 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 {
public void setWidth(int width) {
this.width = width;
this.height = width;
}
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
In the above code, the Square
class violates the LSP because it changes the behavior of the setWidth()
and setHeight()
methods. To adhere to the LSP, we can refactor the code as follows:
public abstract class Shape {
public abstract int getArea();
}
public class Rectangle extends Shape {
private int width;
private 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 Shape {
private int side;
public void setSide(int side) {
this.side = side;
}
public int getArea() {
return side * side;
}
}
By introducing the Shape
abstract class and making both Rectangle
and Square
inherit from it, we ensure that they can be used interchangeably without any unexpected behavior.
Interface Segregation Principle (ISP)
The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. In other words, it is better to have multiple smaller interfaces than a single large interface.
Consider the following example:
public interface Printer {
void print();
void scan();
void fax();
}
public class OfficePrinter implements Printer {
public void print() {
// Print a document
}
public void scan() {
// Scan a document
}
public void fax() {
// Fax a document
}
}
public class HomePrinter implements Printer {
public void print() {
// Print a document
}
public void scan() {
// Scan a document
}
public void fax() {
// Not supported by a home printer
throw new UnsupportedOperationException("Fax is not supported by a home printer");
}
}
In the above code, the Printer
interface violates the ISP because the HomePrinter
class is forced to implement the fax()
method even though it is not supported. To adhere to the ISP, we can refactor the code as follows:
public interface Printer {
void print();
}
public interface Scanner {
void scan();
}
public interface Fax {
void fax();
}
public class OfficePrinter implements Printer, Scanner, Fax {
public void print() {
// Print a document
}
public void scan() {
// Scan a document
}
public void fax() {
// Fax a document
}
}
public class HomePrinter implements Printer, Scanner {
public void print() {
// Print a document
}
public void scan() {
// Scan a document
}
}
By splitting the Printer
interface into smaller interfaces, we allow the classes to implement only the methods they need, resulting in cleaner and more focused code.
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.
Consider the following example:
public class OrderProcessor {
private Database database;
public OrderProcessor() {
this.database = new Database();
}
public void processOrder(Order order) {
// Process the order using the database
}
}
public class Database {
public void saveOrder(Order order) {
// Save the order to the database
}
}
public class Order {
// Order details
}
In the above code, the OrderProcessor
class violates the DIP by directly depending on the Database
class. To adhere to the DIP, we can refactor the code using dependency injection:
public class OrderProcessor {
private Database database;
public OrderProcessor(Database database) {
this.database = database;
}
public void processOrder(Order order) {
// Process the order using the injected database
}
}
public interface Database {
void saveOrder(Order order);
}
public class DatabaseImpl implements Database {
public void saveOrder(Order order) {
// Save the order to the database
}
}
public class Order {
// Order details
}
By introducing the Database
interface and injecting the dependency into the OrderProcessor
class, we decouple the high-level module from the low-level module, allowing for easier testing, maintainability, and future changes.
Conclusion
The SOLID principles provide a set of guidelines that help developers design software that is modular, flexible, and maintainable. By understanding and applying these principles, you can improve the quality of your code and make it more resilient to changes.
In this article, we explored each of the SOLID principles and provided real-time examples in Java to illustrate their importance and application. By adhering to these principles, you can create code that is easier to understand, test, and extend, resulting in more robust and scalable software.