The SOLID principles of object-oriented programming help overcome these challenges by promoting clean architecture and modular design. They provide clear guidelines that make applications more flexible, easier to understand, and simpler to extend or refactor. By applying SOLID, developers can build robust, maintainable, and scalable software systems.
1. S – Single Responsibility Principle (SRP)
A class should have only one reason to change.
In other words, each class should focus on a single responsibility. This reduces the risk of side effects and makes the code easier to maintain.
In real-world applications, classes often tend to grow over time, accumulating multiple responsibilities.
For example, a Car
class might start by handling driving, but then developers often add methods
for fueling, repairing, and logging trips. While this seems convenient at first, it quickly becomes difficult
to maintain. Every time a fueling mechanism changes or a repair logic is updated, you have to modify the same
Car
class. This increases the chance of introducing bugs into unrelated functionalities.
The Single Responsibility Principle, as defined by Robert C. Martin (Uncle Bob), encourages splitting responsibilities into separate classes or modules, each with one reason to change. This design leads to modular, easier-to-test, and more maintainable code. It also promotes reusability, since independent classes can often be used in different contexts without modification.
Applying SRP is not just about splitting classes arbitrarily. It requires analyzing the domain and understanding which responsibilities are truly independent. For instance, driving a car is fundamentally different from refueling or repairing it. Therefore, keeping these concerns in separate classes ensures that changes in one area do not impact others.
Below is a practical example showing a class that violates SRP, followed by a refactored version adhering to the principle.
// Class violating SRP
public class Car {
public void drive() {
System.out.println("Driving the car");
}
public void refuel(int liters) {
System.out.println("Adding " + liters + " liters");
}
public void performRepair(String issue) {
System.out.println("Repairing: " + issue);
}
}
In this design, Car
has multiple responsibilities: driving, fueling, and repairing.
If any of these responsibilities change, the Car
class must be modified, violating SRP.
// Refactored version following SRP
public class Car {
public void drive() {
System.out.println("Driving the car");
}
}
public class FuelService {
public void refuel(Car car, int liters) {
System.out.println("Adding " + liters + " liters to car");
}
}
public class RepairService {
public void repair(Car car, String issue) {
System.out.println("Repairing: " + issue);
}
}
With this design, each class has a single responsibility:
- Car → responsible only for driving.
- FuelService → responsible only for fueling.
- RepairService → responsible only for repairs.
This separation ensures modularity, easier maintenance, and reduces the risk of side effects when changes are made to one part of the system. It also makes the code easier to understand and test, improving overall software quality.
2. O – Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
Put simply, a class or module should allow new functionality to be added without requiring changes to its existing code. This ensures stability while enabling the software to evolve over time.
Consider a scenario in an e-commerce system where we need to calculate shipping costs for different types
of orders. Initially, we might have a single ShippingCalculator
class handling standard and
express shipping.
Problem 1 – Initial design violating OCP
// Single class handling multiple shipping types
public class ShippingCalculator {
public double calculate(String type, double weight) {
if (type.equals("STANDARD"))
{
return weight * 1.0;
}
else if (type.equals("EXPRESS"))
{
return weight * 2.0;
}
// Adding a new shipping type will require modifying this class
return 0;
}
}
In this design, every time a new shipping method is introduced (e.g., overnight shipping),
the ShippingCalculator
class must be modified. This is risky and violates the Open/Closed Principle.
Solution – Refactoring using OCP
Instead of modifying the existing class, we can define a ShippingStrategy
interface and
create separate classes for each shipping type. This way, adding new shipping methods only requires
adding new classes.
// Interface defining the contract
public interface ShippingStrategy {
double calculate(double weight);
}
// Standard shipping implementation
public class StandardShipping implements ShippingStrategy {
public double calculate(double weight) {
return weight * 1.0;
}
}
// Express shipping implementation
public class ExpressShipping implements ShippingStrategy {
public double calculate(double weight)
{
return weight * 2.0;
}
}
// Shipping processor that uses the strategy
public class ShippingProcessor {
private final ShippingStrategy strategy;
public ShippingProcessor(ShippingStrategy strategy)
{
this.strategy = strategy;
}
public double process(double weight)
{
return strategy.calculate(weight);
}
}
Now, each shipping type is implemented in its own class:
- StandardShipping → calculates standard shipping
- ExpressShipping → calculates express shipping
- ShippingProcessor → remains unchanged when new shipping types are added
With this approach:
- The system is open for extension – you can add new shipping strategies easily.
- The system is closed for modification – existing code does not need to be touched.
Breaking the problem into smaller responsibilities and using a strategy interface makes the code modular, maintainable, and less prone to errors when extending functionality in the future.