SOLID Design Principles Examples in Python

SOLID Design Principles Examples in Python
Photo by Jonas Von Werne / Unsplash

I already explained what is SOLID Principles in a previous article and here are examples in Python for each of the SOLID principles to make it more clear:

Single Responsibility Principle (SRP)

The SRP states that a class should have only one reason to change. In other words, a class should have only one responsibility. Here's an example of a class that violates the SRP:

class Employee:
    def calculate_pay(self):
        # Calculate the employee's pay
        pass

    def save_to_database(self):
        # Save the employee's data to the database
        pass

In this example, the Employee class has two responsibilities: calculating the employee's pay and saving the employee's data to the database. To follow the SRP, we could split this class into two separate classes:

class Employee:
    def calculate_pay(self):
        # Calculate the employee's pay
        pass

class EmployeeRepository:
    def save_to_database(self, employee):
        # Save the employee's data to the database
        pass

Now we have one class responsible for calculating the employee's pay and another class responsible for saving the employee's data to the database.

Open/Closed Principle (OCP)

The OCP states that classes should be open for extension but closed for modification. In other words, you should be able to add new functionality to a class without changing its existing code. Here's an example of a class that violates the OCP:

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def calculate_bonus(self):
        return self.salary * 0.1

In this example, the Employee class calculates a bonus based on the employee's salary. If we want to calculate a bonus based on other factors, such as the employee's performance or the company's revenue, we would need to modify the Employee class. To follow the OCP, we could use inheritance to create a new class for calculating bonuses:

class BonusCalculator:
    def calculate_bonus(self, employee):
        pass

class SalaryBonusCalculator(BonusCalculator):
    def calculate_bonus(self, employee):
        return employee.salary * 0.1

class PerformanceBonusCalculator(BonusCalculator):
    def calculate_bonus(self, employee):
        # Calculate bonus based on performance
        pass

class RevenueBonusCalculator(BonusCalculator):
    def calculate_bonus(self, employee):
        # Calculate bonus based on revenue
        pass

Now we have one class responsible for calculating bonuses and multiple subclasses that can calculate bonuses based on different criteria.

Liskov Substitution Principle (LSP)

The LSP states that objects of a superclass should be replaceable with objects of a subclass without changing the correctness of the program. In other words, subclasses should be able to be used in place of their parent classes without causing unexpected behavior. Here's an example of a class hierarchy that violates the LSP:

class Bird:
    def fly(self):
        pass

class Ostrich(Bird):
    def fly(self):
        raise NotImplementedError()

In this example, the Ostrich class is a subclass of Bird, but it cannot fly. If we try to use an Ostrich object in place of a Bird object, we will get an unexpected NotImplementedError. To follow the LSP, we could split the Bird class into two separate classes:

class Bird:
    def fly(self):
        pass

class FlyingBird(Bird):
    def fly(self):
        pass

class Ostrich(Bird):
    pass

Now we have one class for birds that can fly and another class for birds that cannot fly.

Interface Segregation Principle (ISP)

The ISP states that clients should not be forced to depend on methods they do not use. In other words, interfaces should be tailored to the needs of their clients. Here's an example of a class that violates the ISP:

class Printer:
    def print(self, document):
        pass

    def scan(self, document):
        pass

In this example, the Printer class provides both printing and scanning functionality. If a client only needs printing functionality, they will be forced to depend on the scan method. To follow the ISP, we could split the Printer class into two separate interfaces:

class Printer:
    def print(self, document):
        pass

class Scanner:
    def scan(self, document):
        pass

Now we have two separate interfaces, one for printing and one for scanning, that clients can use independently.

Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. In addition, abstractions should not depend on details. Instead, details should depend on abstractions. Here's an example of a class that violates the DIP:

class EmployeeService:
    def __init__(self, employee_repository):
        self.employee_repository = employee_repository

    def get_employee_by_id(self, employee_id):
        return self.employee_repository.get_by_id(employee_id)

In this example, the EmployeeService class depends on the EmployeeRepository class, which is a low-level module. To follow the DIP, we could introduce an abstraction that both the EmployeeService and EmployeeRepository classes depend on:

class EmployeeRepository:
    def get_by_id(self, employee_id):
        pass

class EmployeeService:
    def __init__(self, employee_repository):
        self.employee_repository = employee_repository

    def get_employee_by_id(self, employee_id):
        return self.employee_repository.get_by_id(employee_id)

class SqlEmployeeRepository(EmployeeRepository):
    def get_by_id(self, employee_id):
        # Retrieve employee from SQL database
        pass

class MongoEmployeeRepository(EmployeeRepository):
    def get_by_id(self, employee_id):
        # Retrieve employee from MongoDB
        pass

Now we have an abstraction, the EmployeeRepository class, that both the EmployeeService and SqlEmployeeRepository and MongoEmployeeRepository classes depend on. This allows us to easily switch between different database implementations without changing the EmployeeService class.

These examples show how each of the SOLID principles can be applied in Python code to create more maintainable, flexible, and reusable software.

Conclusion

The SOLID principles provide a set of guidelines for writing clean and maintainable code. By following these principles, we can write code that is more modular, flexible, and reusable, making it easier to maintain and extend over time.

In Python, we can apply the SOLID principles in a variety of ways, such as using inheritance and polymorphism to adhere to the LSP, creating small and focused classes that adhere to the SRP, using dependency injection to adhere to the DIP, and splitting interfaces to adhere to the ISP. By applying these principles, we can write code that is easier to understand, modify, and extend, ultimately leading to more robust and maintainable software.

It's worth noting that while the SOLID principles are generally considered to be good coding practices, they are not always applicable in every situation. As with any programming guideline or best practice, it's important to use good judgement and apply the principles in a way that makes sense for the specific requirements and constraints of your project.

In conclusion, the SOLID principles are an important set of guidelines for writing clean and maintainable code. By following these principles in our Python code, we can create software that is more flexible, reusable, and easier to maintain and extend over time.