When we write code, making it work is only one part of the job. As requirements change and applications grow, we also need to think about how easy our code is to understand, maintain, and extend.
While learning software design, I came across the SOLID principles. I didn't want to just memorize their definitions, so I tried to understand each principle through real-life situations and then relate it to code.
In this article, I'll share those examples to explain the five SOLID principles in simple terms.
S: Single Responsibility Principle
A real-life example
The owner of my hostel also managed a curry point.
Whenever something went wrong in the hostel, he had to spend time fixing it. During that time, managing the curry point became difficult. The same thing happened when there was a problem at the curry point.
Both businesses depended on the same person, so a problem in one affected the other.
Now, imagine having separate people to manage the hostel and the curry point. A problem in the hostel could be handled by the hostel manager while the curry point continued its work.
We can apply a similar idea when designing software.
What is a responsibility?
A responsibility is the job a class is meant to handle.
Let's say the owner builds software to manage both businesses.
class BusinessManager:
def book_hostel_room(self):
pass
def schedule_hostel_repairs(self):
pass
def take_food_order(self):
pass
def calculate_food_bill(self):
pass
This class handles two different responsibilities:
Hostel management
- Booking rooms
- Scheduling repairs
Curry point management
- Taking food orders
- Calculating food bills
Although all these methods belong to the same BusinessManager class, they handle two different areas of work.
If hostel booking rules change, we should not need to modify the code that handles food orders. Similarly, a change in food billing should not affect hostel management.
We can separate these responsibilities into two classes.
class HostelManager:
def book_room(self):
pass
def schedule_repairs(self):
pass
class CurryPointManager:
def take_food_order(self):
pass
def calculate_food_bill(self):
pass
Now, hostel-related changes can be handled in HostelManager, and curry point-related changes can be handled in CurryPointManager.
Notice that we did not create a separate class for every method. Room booking and repair scheduling can stay together because both are part of hostel management.
The principle
The Single Responsibility Principle (SRP) states:
A class should have only one reason to change.
This does not mean a class should have only one method. It means that the methods in a class should belong to one clear responsibility.
When a class handles different responsibilities that change for different reasons, we should consider separating them.
The goal is to make changes in one area without unnecessarily affecting other areas.
O: Open/Closed Principle
I was thinking of building an app for my uncle to manage his curry point.
He sells egg curry and chicken curry. I wanted the app to help him manage the items and their prices.
Now, suppose he decides to add mutton curry to the menu. I would have to change the code to add its price.
What if he wants to add another curry later? I would have to make changes again.
I wanted to write the code in a way that adding a new curry would be easier, without changing the code that already works.
What is the Open/Closed Principle?
The Open/Closed Principle says:
Software entities should be open for extension, but closed for modification.
In simple words, we should be able to add new features without having to change the existing code every time.
1. My first approach
I can write a function to calculate the price of each curry.
def calculate_price(item):
if item == "egg_curry":
return 80
elif item == "chicken_curry":
return 150
This works for the current menu.
But when my uncle wants to add mutton curry, I need to modify the same function.
elif item == "mutton_curry":
return 250
Every time he adds a new curry, I have to add another condition to this function.
Can we avoid changing this function whenever a new curry is added?
2. Create a common class for curries
Let's create a class called Curry that defines a common method for calculating prices.
from abc import ABC, abstractmethod
class Curry(ABC):
@abstractmethod
def calculate_price(self):
pass
The Curry class defines a rule: every curry must have a calculate_price() method.
It doesn't calculate the actual price. Each curry will provide its own implementation.
3. Create classes for each curry
Now, let's create classes for egg curry and chicken curry.
class EggCurry(Curry):
def calculate_price(self):
return 80
class ChickenCurry(Curry):
def calculate_price(self):
return 150
Each class implements the same method, but returns a different price.
4. Write the billing function
Now, we can write a common function to calculate the price.
def generate_bill(curry: Curry):
return curry.calculate_price()
Let's use it:
print(generate_bill(EggCurry())) # 80
print(generate_bill(ChickenCurry())) # 150
Notice that generate_bill() doesn't check whether the curry is egg curry or chicken curry. It simply calls calculate_price().
Python calls the method implemented by the actual object. This is called polymorphism.
5. Add mutton curry
Now my uncle wants to add mutton curry to the menu.
I just need to create another class.
class MuttonCurry(Curry):
def calculate_price(self):
return 250
We can use the same billing function:
print(generate_bill(MuttonCurry()) # 250
When my uncle wants to add mutton curry, I don't have to touch the generate_bill() function. I just add the MuttonCurry class.
The same function works for egg curry, chicken curry, and mutton curry. That's what I wanted from the design.
L: Liskov Substitution Principle
LSP says that
Objects of a child class should be replaceable with objects of their parent class without breaking the application.
Let's understand this with an example.
An Authentication Example
Imagine we are building an application that supports different authentication methods, such as passwords, biometrics, and two-factor authentication.
We create a parent class called AuthenticationProvider.
class AuthenticationProvider:
def authenticate(self, credentials: dict) -> bool:
"""
Returns True if authentication succeeds,
or False if the credentials are invalid.
"""
raise NotImplementedError
The contract is simple. Every authentication provider must return True or False.
Password Authentication
class PasswordAuthProvider(AuthenticationProvider):
def authenticate(self, credentials: dict) -> bool:
return database.verify(
credentials["user"],
credentials["pass"]
)
If the credentials are valid, it returns True. Otherwise, it returns False.
This follows the parent's contract.
Biometric Authentication
class BiometricAuthProvider(AuthenticationProvider):
def authenticate(self, credentials: dict) -> bool:
return mobile_hardware.verify_fingerprint(
credentials["fingerprint_token"]
)
This also returns True or False, so it follows the same contract.
Two-Factor Authentication
Now, let's implement authentication using SMS verification.
class TwoFactorSmsAuthProvider(AuthenticationProvider):
def authenticate(self, credentials: dict) -> bool:
sms_gateway.send_pin(credentials["phone_number"])
raise MultiFactorChallengeRequired()
This implementation sends an SMS PIN but then raises an exception instead of returning a Boolean.
The problem is not that two-factor authentication requires an extra step. The problem is that this implementation breaks the contract promised by the parent class.
How Does This Break the Application?
Consider the client code:
def process_user_login(provider, user_data):
is_logged_in = provider.authenticate(user_data)
if is_logged_in:
display_dashboard()
else:
display_error_message("Invalid credentials")
This client expects authenticate() to return a Boolean.
It works with password and biometric authentication. But when we pass TwoFactorSmsAuthProvider, the unexpected exception interrupts the flow.
The child class cannot safely replace the parent class in this client.
That is an LSP violation.
How Can We Fix It?
We need to design a contract that supports all the expected authentication outcomes.
For example, authentication can return one of these results:
SUCCESSINVALID_CREDENTIALSMFA_REQUIRED
The two-factor provider can send the PIN and return MFA_REQUIRED instead of throwing an unexpected exception.
The client can handle these common outcomes:
def process_user_login(provider, user_data):
result = provider.authenticate(user_data)
if result == "SUCCESS":
display_dashboard()
elif result == "INVALID_CREDENTIALS":
display_error_message("Invalid credentials")
elif result == "MFA_REQUIRED":
display_pin_screen()
Now, every provider follows the same contract. The client handles the defined outcomes without needing to know which concrete provider it is using.
The if/elif statements are not the problem here. They handle common outcomes defined by the contract. The problem would be forcing the client to add separate logic for each authentication provider.
I: Interface Segregation Principle
The Interface Segregation Principle (ISP) says that a class should not be forced to implement methods it does not need.
Instead of creating one large interface with many methods, we should split it into smaller interfaces based on what different classes actually need.
The Problem: One Large Interface
Imagine we are building a user management system. We have two types of users: Customers and Administrators.
Customers can log in and change their passwords. Administrators can do these things too, but they can also delete user accounts.
Now, suppose we put all these actions into a single IUser interface.
class IUser:
def login(self) -> None:
pass
def change_password(self) -> None:
pass
def delete_user_account(self) -> None:
pass
When we create a Customer class, it must implement all three methods, even though customers are not allowed to delete user accounts.
class Customer(IUser):
def login(self) -> None:
print("Customer logged in.")
def change_password(self) -> None:
print("Password changed.")
def delete_user_account(self) -> None:
pass
The delete_user_account() method does nothing for a customer. We have included it only because the interface forces us to implement it.
This is the problem ISP helps us avoid.
The Solution: Split the Interface
Instead of putting every action into one interface, we can separate the actions based on their responsibilities.
class IStandardUser:
def login(self) -> None:
pass
def change_password(self) -> None:
pass
class IAdminUser:
def delete_user_account(self) -> None:
pass
Now, each interface represents a smaller set of actions.
1. Customer
A customer needs only standard user actions.
class Customer(IStandardUser):
def login(self) -> None:
print("Customer logged in.")
def change_password(self) -> None:
print("Password changed.")
The Customer class no longer needs an unnecessary delete_user_account() method.
2. Administrator
An administrator can perform both standard user actions and administrative actions. So, the class can implement both interfaces.
class Administrator(IStandardUser, IAdminUser):
def login(self) -> None:
print("Admin logged in.")
def change_password(self) -> None:
print("Admin password changed.")
def delete_user_account(self) -> None:
print("Admin deleted an account.")
Each class now implements only the actions it is expected to support.
D: Dependency Inversion Principle
Imagine you are working as a backend developer in a software company. You know how to build applications using Django.
Your company currently uses one technology, but after some time, the team decides to move to a different technology.
If you know only Django and are unwilling to learn anything new, you may struggle to adapt to the team's changing requirements.
The same situation can happen in software design.
Imagine we have an application that sends SMS notifications using Twilio. If our code is tightly connected to Twilio, switching to another provider, such as Vonage, may require us to change the existing code.
Instead, we can design our code so that it does not depend on one specific SMS provider. Then, we can change the provider without changing the main business logic.
This is the idea behind the Dependency Inversion Principle.
What Is Dependency Inversion Principle?
The Dependency Inversion Principle says that high-level modules should not depend directly on low-level modules. Both should depend on abstractions.
In simple words, our main business logic should not depend directly on a specific implementation.
The Problem: Depending Directly on Twilio
Imagine our application sends an SMS whenever a customer places an order.
class TwilioService:
def send_sms(self, message: str) -> None:
print(f"Sending SMS through Twilio: {message}")
class OrderService:
def __init__(self):
self.sms_service = TwilioService()
def place_order(self) -> None:
print("Order placed.")
self.sms_service.send_sms("Your order has been placed!")
Here, OrderService creates a TwilioService object directly.
If we want to switch to Vonage, we need to change how OrderService creates and uses the SMS service.
The order-processing logic is unnecessarily tied to Twilio.
How Can We Fix It?
We can define a common interface that describes how an SMS service should work.
from abc import ABC, abstractmethod
class SMSService(ABC):
@abstractmethod
def send_sms(self, message: str) -> None:
pass
Now, both providers can implement this interface.
class TwilioService(SMSService):
def send_sms(self, message: str) -> None:
print(f"Sending SMS through Twilio: {message}")
class VonageService(SMSService):
def send_sms(self, message: str) -> None:
print(f"Sending SMS through Vonage: {message}")
Next, we update OrderService to receive an SMS service instead of creating a Twilio object itself.
class OrderService:
def __init__(self, sms_service: SMSService):
self.sms_service = sms_service
def place_order(self) -> None:
print("Order placed.")
self.sms_service.send_sms("Your order has been placed!")
Now, we can choose the provider when creating the service.
order_service = OrderService(TwilioService())
Or we can use Vonage instead.
order_service = OrderService(VonageService())
We do not need to change the OrderService class when switching providers.
⭐ Hope this helps! 😊
I was really confused about the difference between LSP and ISP. I went through several Reddit discussions, Stack Overflow answers, and other resources to understand them better.
If you're struggling with the same confusion, let's discuss it in the comments!







Top comments (0)