What is SOLID principle all about?
SOLID is an acronym that stands for a set of five fundamental design principles in object-oriented programming that can improve the quality of your code.
S - Single Responsibility Principle (SRP)
O - Open/Closed Principle (OCP)
L - Liskov Substitution Principle (LSP)
I - Interface Segregation Principle (ISP)
D - Dependency Inversion Principle (DIP)
They have been time-tested and widely adopted by experienced developers to create scalable and flexible systems.
In this beginner-friendly guide, we will go through each SOLID principle using simple explanations and practical examples, helping you to write cleaner, more maintainable software.
1. Single Responsibility Principle(SRP)
SRP states that a class should have only one reason to change, meaning it should have only one responsibility or job.
Let's consider an e-commerce application where users can browse products, add items to the cart, and place orders. Following SRP, we should have separate classes for each of these responsibilities: a ProductCatalog
class for browsing products, a ShoppingCart
class for managing cart operations, and an OrderProcessor
class for handling order placement. This way, if there are changes in the cart logic, it won't affect the product browsing or order processing code.
Let's understand the SRP with code examples both with and without adhering to this principle:
Without SRP:
class Order {
private String orderId;
private String customerName;
private List<Item> items;
// Methods for managing order data (e.g., getOrderId, getCustomerName, addItem, removeItem, etc.)
public void printOrderInvoice() {
// Code to generate and print the invoice for the order
}
public void sendOrderConfirmationEmail() {
// Code to send an email to the customer confirming the order
}
}
class Item {
private String itemId;
private String itemName;
private double price;
// Methods for managing item data (e.g., getItemId, getItemName, getPrice, etc.)
}
In the above non-SRP-compliant example, the Order
class has multiple responsibilities: managing order data, printing order invoices, and sending order confirmation emails.
This violates the SRP as the class should ideally have only one reason to change, but here, changes in the order invoice printing logic or email sending logic will require modifications to the Order
class.
With SRP:
class Order {
private String orderId;
private String customerName;
private List<Item> items;
// Methods for managing order data (e.g., getOrderId, getCustomerName, addItem, removeItem, etc.)
}
class InvoicePrinter {
public void printOrderInvoice(Order order) {
// Code to print the invoice for the order
}
}
class EmailSender {
public void sendOrderConfirmationEmail(String recipient, String orderId) {
// Code to send an email to the customer confirming the order
}
}
class Item {
private String itemId;
private String itemName;
private double price;
// Methods for managing item data (e.g., getItemId, getItemName, getPrice, etc.)
}
In this SRP-compliant example, we have extracted the responsibilities of printing order invoices and sending order confirmation emails into separate classes: InvoicePrinter
and EmailSender
, respectively.
The Order
class now focuses solely on managing order data, adhering to the Single Responsibility Principle. Changes to invoice printing or email-sending logic will not affect the Order
class, which helps better maintainability and code organisation.
2. Open/Closed Principle (OCP)
OCP suggests that classes should be open for extension but closed for modification, allowing new features to be added without altering the existing code.
Let's say we have a tax calculator class (TaxCalculator
) that calculates taxes for different states in India.
Instead of modifying the existing class every time there's a new tax calculation logic for a different state, we can create new classes for specific states, like MaharashtraTaxCalculator
or DelhiTaxCalculator
, which extends the base TaxCalculator
class.
This way, the original TaxCalculator
remains unchanged, and new tax calculation logic can be added for specific states.
Let's illustrate the Open/Closed Principle with code examples both with and without adhering to this principle:
Without OCP:
Suppose we have a simple class Calculator
that performs basic arithmetic operations such as addition and subtraction:
class Calculator {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
}
Now, imagine that we need to extend the Calculator
class to support multiplication and division operations. Without adhering to the OCP, we would directly modify the existing Calculator
class:
class Calculator {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("Cannot divide by zero");
}
return a / b;
}
}
In this non-OCP example, we modified the Calculator
class by adding the multiply()
and divide()
methods.
While this approach may seem simple, it violates the OCP because we have directly modified the existing class.
As a result, any changes or additions to the calculator's functionality would require modifications to the Calculator
class, which can be a risk for introducing bugs.
With OCP:
To adhere to the Open/Closed Principle, we would create an abstract Operation
class or interface that defines the contract for arithmetic operations:
interface Operation {
int calculate(int a, int b);
}
Next, we create concrete classes for each arithmetic operation, implementing the Operation
interface:
class Addition implements Operation {
@Override
public int calculate(int a, int b) {
return a + b;
}
}
class Subtraction implements Operation {
@Override
public int calculate(int a, int b) {
return a - b;
}
}
class Multiplication implements Operation {
@Override
public int calculate(int a, int b) {
return a * b;
}
}
class Division implements Operation {
@Override
public int calculate(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("Cannot divide by zero");
}
return a / b;
}
}
Finally, we modify the Calculator
class to accept any arithmetic operation through the Operation
interface, without modifying the Calculator
class itself:
class Calculator {
public int calculate(Operation operation, int a, int b) {
return operation.calculate(a, b);
}
}
With OCP, the Calculator
class is open for extension because we can add new arithmetic operations (e.g., exponentiation, square root) by creating new classes that implement the Operation
interface, without modifying the existing Calculator
class.
By following the Open/Closed Principle, we create more flexible and extensible code, reducing the risk of introducing bugs and promoting better software design practices.
3. Liskov Substitution Principle (LSP)
LSP states that objects of a superclass should be replaceable with objects of its subclasses without breaking the system.
In other words, a subclass should behave in such a way that it can be used as a substitute for its superclass without causing any unexpected behaviours or breaking the program's functionality.
Imagine an application that handles different types of bank accounts. We have a base class called BankAccount
, and two subclasses, SavingsAccount
and CurrentAccount
. According to LSP, any code that works with a BankAccount
should work seamlessly with its subclasses. For instance, if there's a method that calculates interest for a BankAccount
, it should work equally well with SavingsAccount
and CurrentAccount
.
class Vehicle {
public void start() {
System.out.println("Vehicle is starting...");
}
}
class Car extends Vehicle {
@Override
public void start() {
System.out.println("Car is starting...");
}
}
class Bicycle extends Vehicle {
@Override
public void start() {
System.out.println("Bicycle is starting...");
}
}
In this example, we have a class Vehicle
that serves as the superclass, and Car
and Bicycle
are subclasses inheriting from it. Each subclass provides its implementation of the start()
method.
Now, let's examine how the Liskov Substitution Principle applies:
Without LSP:
class Garage {
public void startVehicle(Vehicle vehicle) {
vehicle.start();
}
}
In this non-LSP-compliant example, we have a Garage
class that attempts to start a vehicle by calling the start()
method. Initially, everything seems to work fine, as we can start both Car
and Bicycle
objects using the startVehicle()
method:
Garage garage = new Garage();
garage.startVehicle(new Car()); // Output: Car is starting...
garage.startVehicle(new Bicycle()); // Output: Bicycle is starting...
However, let's say we introduce a new subclass Motorcycle
:
class Motorcycle extends Vehicle {
@Override
public void start() {
System.out.println("Motorcycle is starting...");
}
}
Now, if we pass a Motorcycle
object to the startVehicle()
method, it still works correctly:
garage.startVehicle(new Motorcycle()); // Output: Motorcycle is starting...
But what if we mistakenly override the start()
method for Motorcycle
incorrectly:
class Motorcycle extends Vehicle {
//...
@Override
public void start() {
super.start(); // Incorrect implementation, calling the superclass method
System.out.println("Motorcycle engine is starting...");
}
}
In this case, the Motorcycle
class violates the Liskov Substitution Principle because it alters the behaviour of the start()
method from the superclass Vehicle
. Now, when we pass a Motorcycle
object to the startVehicle()
method, it will print both the superclass message ("Vehicle is starting...") and the subclass message ("Motorcycle engine is starting..."), breaking the expected behaviour.
With LSP:
To adhere to the LSP, we must ensure that any subclass can be safely used in place of its superclass without altering the program's correctness.
class Vehicle {
public void start() {
System.out.println("Vehicle is starting...");
}
}
class Car extends Vehicle {
//...
@Override
public void start() {
System.out.println("Car is starting...");
}
}
class Bicycle extends Vehicle {
//...
@Override
public void start() {
System.out.println("Bicycle is starting...");
}
}
class Motorcycle extends Vehicle {
//...
@Override
public void start() {
System.out.println("Motorcycle is starting...");
}
}
In this LSP-compliant example, each subclass (Car
, Bicycle
, and Motorcycle
) correctly implements its version of the start()
method without altering the behaviour defined in the superclass Vehicle
. Now, when we pass any object of these subclasses to the startVehicle()
method, will start the vehicle appropriately without any unexpected results.
By adhering to the Liskov Substitution Principle, we ensure that our code is more flexible, reliable, and maintainable, as each subclass can be used interchangeably with its superclass, promoting better object-oriented design.
4. Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) states that a class should not be forced to implement interfaces it does not use.
In other words, a class should have separate and specific interfaces based on its needs, rather than having a single large interface that contains methods irrelevant to the class.
This principle encourages the creation of fine-grained interfaces tailored to the requirements of individual classes.
To illustrate the ISP, let's consider a scenario related to a food delivery application that caters to different types of restaurants.
Without ISP:
Suppose we have an interface called Restaurant
that represents the functionalities a restaurant can have:
interface Restaurant {
void acceptOnlineOrder();
void takeTelephoneOrder();
void payOnline();
void walkInCustomerOrder();
void serveFood();
}
class IndianRestaurant implements Restaurant {
//... (implementation for all methods)
}
class ItalianRestaurant implements Restaurant {
//... (implementation for all methods)
}
In this non-ISP-compliant example, the Restaurant
interface includes methods for both online orders and walk-in orders.
However, not all restaurants support online orders, and not all restaurants support walk-in orders.
For instance, an Indian restaurant may not accept online orders, and an Italian restaurant may not have a walk-in customer facility.
With ISP:
To adhere to the Interface Segregation Principle, we should segregate the Restaurant
interface into more specific interfaces, based on functionalities that are relevant to different types of restaurants:
interface OnlineOrderRestaurant {
void acceptOnlineOrder();
void payOnline();
}
interface WalkInOrderRestaurant {
void walkInCustomerOrder();
void serveFood();
}
class IndianRestaurant implements WalkInOrderRestaurant {
//... (implementation for all relevant methods)
}
class ItalianRestaurant implements OnlineOrderRestaurant, WalkInOrderRestaurant {
//... (implementation for all relevant methods)
}
In this ISP-compliant example, we have segregated the Restaurant
interface into two smaller interfaces: OnlineOrderRestaurant
and WalkInOrderRestaurant
. Now, each class implements only the interfaces that are relevant to its functionalities.
The IndianRestaurant
class implements WalkInOrderRestaurant
, as it supports walk-in customer orders and serves food in the restaurant.
On the other hand, the ItalianRestaurant
class implements both OnlineOrderRestaurant
and WalkInOrderRestaurant
, as it supports both online orders and walk-in customer orders.
By adhering to the Interface Segregation Principle, we create more specialized interfaces that cater to the needs of individual classes.
This approach leads to better code organization, reduces dependencies, and improves the flexibility of the system, making it easier to extend and maintain as new types of restaurants or functionalities are added to the food delivery application.
5.Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions.
In simpler words, DIP suggests that when designing software, the relationships between different components should be based on abstract interfaces or classes, rather than concrete implementations.
Let's understand the Dependency Inversion Principle (DIP) both with and without code examples.
Without DIP:
Suppose we have a simple logging system that writes log messages to a file. The main class directly depends on the low-level file handling class, which creates a tight coupling between them.
class FileLogger {
public void logMessage(String message) {
// Code to write log message to a file
}
}
class MainClass {
private FileLogger fileLogger = new FileLogger();
public void doSomething() {
// Some business logic here...
fileLogger.logMessage("Doing something...");
}
}
In this non-DIP-compliant example, MainClass
depends directly on FileLogger
, which represents a low-level file handling class. Any change in the file handling mechanism or a decision to use a different logging method will require modifications in the MainClass
. This tightly couples the high-level class with low-level details, making the code less flexible and harder to maintain.
With DIP:
Now, let's refactor the code to adhere to the Dependency Inversion Principle by introducing an abstract interface to represent the logger.
interface Logger {
void logMessage(String message);
}
class FileLogger implements Logger {
@Override
public void logMessage(String message) {
// Code to write log message to a file
}
}
class MainClass {
private Logger logger;
public MainClass(Logger logger) {
this.logger = logger;
}
public void doSomething() {
// Some business logic here...
logger.logMessage("Doing something...");
}
}
In this DIP-compliant example, we have introduced the Logger
interface, which abstracts the logging behaviour. The MainClass
no longer depends directly on the FileLogger
class but instead depends on the abstract Logger
interface.
By adhering to DIP, the high-level MainClass
is now decoupled from the low-level FileLogger
class. This allows us to use different implementations of the Logger
interface without modifying MainClass
. For example, we can create a ConsoleLogger
or a DatabaseLogger
that also implements the Logger
interface, and pass it to MainClass
without changing its code. This flexibility and decoupling promote better code organization and maintainability.
In summary, the Dependency Inversion Principle suggests using abstract interfaces to decouple high-level components from low-level details, resulting in more flexible, modular, and maintainable code.
Conclusion
So, throughout this article, we've explored each principle, breaking down complex concepts into easy-to-understand explanations with practical examples.
In the next blog, we'll learn how to use SOLID principles to improve an existing codebase that doesn't follow these principles. We'll explore practical techniques to make the code easier to maintain and understand.
If you have any queries/feedback/suggestions, do comment below.
Till then, If you've found this guide helpful, please consider liking and sharing it with your colleagues.
Happy Coding!