Complete Guide to SOLID Principles in Java
Learn SOLID Principle with Real-world Example
Photo by Emile Perron on Unsplash
1. What is SOLID?
It is one of the most important design principles in Software Engineering which exists in all OOP Languages.
It helps in making our source code - Modular, Readable, Debuggable, and Maintainable.
SOLID stands for:
Single Responsibility Principle
Open-Closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle
2. Single Responsibility Principle
"A class should always have one responsibility and there should be only a single reason to change it".
Let's understand this with an Employee class which represents a real-world employee of a company.
Let's see a few Implementations of it in regard to this principle.
2.1. Bad Implementation❌
The employee class contains personal details, business logic to perform a few calculations, and DB logic to save/update as shown below:
public class Employee {
private String fullName;
private String dateOfJoining;
private String annualSalaryPackage;
private String type;
// standard getters and setters method
// business logic
public long calculateEmployeeSalary() {...}
public long calculateEmployeeLeaves() {...}
public long calculateTaxOnSalary() {...}
// database persistence logic
public Employee saveEmployee() {...}
public Employee updateEmployee() {...}
}
Basically, it is handling multiple responsibilities at the same time. Due to this, It is tightly coupled, and hard to maintain, and there are multiple reasons to modify the class.
2.2. Good Implementation✔️
Let's split this class into multiple classes as per its specific responsibilities.
Employee class which contains personal data:
public class Employee {
private String fullName;
private String dateOfJoining;
private String annualSalaryPackage;
private String type;
// standard getters and setters method
}
EmployeeService class contains only business logic:
public class EmployeeService {
public long calculateSalary() {...}
public long calculateLeaves() {...}
public long calculateTax() {...}
}
And, EmployeeDAO class contains only database persistence logic:
public class EmployeeDAO {
public Employee saveEmployee() {...}
public Employee updateEmployee() {...}
}
It made our class loosely coupled, and easy to maintain, and we've only a single reason to modify the class.
3. Open-Closed Principle:
"The class should be Open for Extension but Closed for Modification".
Let's consider an Employee class that calculates salary differently for Permanent and Contractual Employees.
Let's see a few implementations of this in regard to this principle.
3.1. Bad Implementation❌
Employee class contains fields related to employee:
public class Employee {
//type indicates employee type like
//Permanent, Contract, Part-time Employees
private String type;
// standard getter and setter method
}
Below EmployeeSalary class calculates salary based on employee type: Permanent and Contract:
public class EmployeeSalary {
public Long calculateSalary(Employee emp) {
Long salary = null;
if (emp.getType().equals("PERMANENT")) {
salary = (totalWorkingDay * basicPay)
+ getCompanyBenefits()
+ getBonus();
} else if (emp.getType().equals("CONTRACT")) {
salary = (totalWorkingDay * basicPay);
}
return salary;
}
}
In the future, if a new type(Part-time Employee) comes then the code needs to be modified to calculate the salary based on the new employee type.
This violates the Open-close principle.
3.2. Good Implementation✔️
Now let's see a better version of the same example.
Let's introduce a new interface EmployeeSalary which contains an abstract method to calculate salary:
public interface EmployeeSalary {
public Long calculateSalary();
}
EmployeeSalary interface contains an abstract method to calculate salary:
Let's create two child classes for Permanent and Contractual Employees:
public class PermanentEmployeeSalary implements EmployeeSalary{
@Override
public Long calculateSalary() {
return (totalWorkingDay * basicPay);
}
}
ContractEmployeeSalary class also defines its own salary calculation logic:
public class ContractEmployeeSalary implements EmployeeSalary{
@Override
public Long calculateSalary() {
return (totalWorkingDay * basicPay) + getCompanyBenefits() + getBonus();
}
}
In this approach, whenever we will have a new employee type, we will have a new child class and our core logic remains unchanged.
This way our code is closed for modification but open for extension.
4. Liskov Substitution
"Child Classes should be replaceable with Parent Classes without breaking the behavior of our code".
Let's understand this in more detail with the help of an example.
4.1. Bad Implementation❌
Consider a class Car which acts as a parent class for both types of car Real and Toy:
public class Car {
public void fuel() {...}
public void wheels() {...}
public void run() {...}
}
TeslaRealCar class is the child class and extends Car class, it supports all three methods. It can be replaceable with its parent class (Car). So far so good.
public class TeslaRealCar extends Car{
@Override
public void fuel() {...}
@Override
public void run() {...}
@Override
public void wheels() {...}
}
TeslaToyCar class is another child class of Car class but it does not support fuel() method as its a toy and does require fuel to run:
public class TeslaToyCar extends Car{
@Override
public void fuel() {
throw new IllegalStateException("Not Supported");
}
@Override
public void run() {...}
@Override
public void wheels() {...}
}
This class is not replaceable with its parent class as it throws exceptions in fuel(), so places, where we are calling fuel(), will break.
This violates the Liskov-substitution principle.
Let's see a better version of the same example.
4.2. Good Implementation✔️
Revised Car class where fuel() method is removed because it's not applicable to all types of car:
public class Car {
public void wheels() {...}
public void run() {...}
}
New class RealCar which contains fuel() method and also extends Car:
public class RealCar extends Car{
public void fuel() {...}
}
Here, RealCar class represents all real-world cars that support fuelling.
TeslaRealCar now extends RealCar class instead of Car:
public class TeslaRealCar extends RealCar{
@Override
public void fuel() {...}
@Override
public void run() {...}
@Override
public void wheels() {...}
}
TeslaToyCar extends Car class:
public class TeslaToyCar extends Car{
@Override
public void run() {...}
@Override
public void wheels() {...}
}
Now, if we notice TeslaToyCar and TeslaRealCar child classes then it is replaceable with their respective parent classes.
This will not break the code if child classes replace parent classes and it is in compliance with the Liskov-Substitution principle.
5. Interface Segregation:
The interface should only have methods that are applicable to all child classes.
If an interface contains a method applicable to some child classes then we need to force the rest to provide dummy implementation.
Move such methods to a new interface.
Let's see below example.
5.1 Bad Implementation❌:
Vehicle interface with three methods accelerate(), brakes() and fly():
public interface Vehicle {
void accelerate();
void applyBrakes();
void fly();
}
Bus class implements Vehicle but it can't fly so it provides a dummy implementation for fly() method:
public class Bus implements Vehicle {
@Override
public void accelerate() {...}
@Override
public void applyBrakes() {...}
@Override
public void fly() {
// dummy implementation
}
}
Aeroplane class supports all methods:
public class Aeroplane implements Vehicle {
@Override
public void accelerate() {...}
@Override
public void applyBrakes() {...}
@Override
public void fly() {...}
}
The fly() method in Vehicle interface is not supported by all vehicles i.e. Bus, Car, etc. Hence they've to forcefully provide a dummy implementation.
It violates the Interface Segregation principle. Let's see a better version below.
5.2 Good Implementation✔️:
Revised Vehicle interface without fly() method:
public interface Vehicle {
void accelerate();
void applyBrakes();
}
New Flyable interface with only fly() method:
public interface Flyable {
void fly();
}
Now, Aeroplane implements both the above interface:
public class Aeroplane implements Vehicle, Flyable {
@Override
public void accelerate() {...}
@Override
public void applyBrakes() {...}
@Override
public void fly() {...}
}
And Bus will only implement Vehicle as it can't fly:
public class Bus implements Vehicle {
@Override
public void accelerate() {...}
@Override
public void applyBrakes() {...}
}
Pulling out the fly() method into the new Flyable interface solves the issue.
Now, the Vehicle interface contains methods supported by all Vehicles. Aeroplane implements both Vehicle and Flyable interface as it can fly too.
6. Dependency Inversion:
The class should depend on abstractions (interface and abstract class) instead of concrete implementations.
It makes our classes de-coupled with each other.
If implementation changes then the class referring to it via abstraction won't change.
6.1. Bad Implementation❌:
Consider, SQLRepository class supports SQL DB related operations:
class SQLRepository{
public void save() {...}
}
NoSQLRepository class supports all NoSQL based DB related operations:
class NoSQLRepository{
public void save() {...}
}
Finally, a Service class wants to use SQL DB so it instantiates SQLRepository class as below:
public class Service {
private SQLRepository repository = new SQLRepository();
public void save() {
repository.save();
}
}
Service class violates the DI principle because when in the future we want to support NoSQLRepository our implementation needs to be changed.
Let's look at a better version of the same example.
6.2. Good Implementation✔️:
Create a parent interface Repository and SQL and NoSQL Repository implements it:
interface Repository{
void save();
}
SQLRepository implements Repository:
class SQLRepository implements Repository{
@Override
public void save() {..}
}
NoSQLRepository implements Repository:
class NoSQLRepository implements Repository{
@Override
public void save() {..}
}
Revised Service class in which we've referred to common interface:
public class Service {
private Repository repository;
public Service(Repository repository) {
this.repository = repository;
}
public void save() {
repository.save();
}
}
Now, as per the new Service class, we can support SQL and NoSQL Repository without changing Service class.
Even in the future, if we want to support any new type of Repository we can do it without affecting the Service class.
7. Conclusion
In this article, we've seen all SOLID principles and understand them with examples in Java. Examples mentioned in this article are available on the github repository.
Thanks for reading the article😃.Keep learning and Keep growing🚀