DEV Community

Anusha Renangi
Anusha Renangi

Posted on

SOLID Principles Explained Through Real-Life Examples

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Let's use it:

print(generate_bill(EggCurry()))      # 80
print(generate_bill(ChickenCurry()))  # 150
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We can use the same billing function:

print(generate_bill(MuttonCurry())  # 250
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
        )
Enter fullscreen mode Exit fullscreen mode

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"]
        )
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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:

  • SUCCESS
  • INVALID_CREDENTIALS
  • MFA_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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

Now, we can choose the provider when creating the service.

order_service = OrderService(TwilioService())
Enter fullscreen mode Exit fullscreen mode

Or we can use Vonage instead.

order_service = OrderService(VonageService())
Enter fullscreen mode Exit fullscreen mode

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)