What Are the SOLID Principles?

March 7, 2023

☕️ Support Us
Your support will help us to continue to provide quality content.👉 Buy Me a Coffee

What Are the SOLID Principles?

The SOLID principles are a set of software design principles used to guide software developers in designing and implementing high-quality, maintainable, and extensible software. It was proposed by Robert C. Martin in his book "Agile Software Development, Principles, Patterns, and Practices" and is widely accepted as a software design concept in the software engineering industry.

Five Principles of SOLID

The SOLID principles include the following five principles:

  1. Single Responsibility Principle (SRP)

A class should have only one reason to change. In other words, a class should have only one responsibility causing it to change. The following example shows this:

class ShoppingCart:
    def __init__(self):
        self.items = []
        self.total = 0

    def add_item(self, item):
        self.items.append(item)
        self.total += item.price

    def remove_item(self, item):
        self.items.remove(item)
        self.total -= item.price

    def print_receipt(self):
        print('Items:')
        for item in self.items:
            print(f'{item.name} - ${item.price}')
        print(f'Total: ${self.total}')

This ShoppingCart class handles both shopping cart-related tasks and output-related tasks. Its print_receipt() method should be separated into an independent class or method to achieve the Single Responsibility Principle.

  1. Open-Closed Principle (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means that when adding new functionality, existing entities should be extended rather than modified.

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, item):
        self.items.append(item)

    def remove_item(self, item):
        self.items.remove(item)

    def get_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return total_price

class DiscountedShoppingCart(ShoppingCart):
    def get_total_price(self):
        total_price = super().get_total_price()
        return total_price * 0.9

In this example, when we need to add a shopping cart with a different discount, we must create a new subclass DiscountedShoppingCart and rewrite the get_total_price() method, which violates the Open-Closed Principle.

Here is a fixed Python code example that complies with the Open-Closed Principle:

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, item):
        self.items.append(item)

    def remove_item(self, item):
        self.items.remove(item)

    def get_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return total_price

class Discount:
    def calculate_discount(self, total_price):
        return total_price

class TenPercentDiscount(Discount):
    def calculate_discount(self, total_price):
        return total_price * 0.9

class ShoppingCartWithDiscount:
    def __init__(self, discount: Discount):
        self.items = []
        self.discount = discount

    def add_item(self, item):
        self.items.append(item)

    def remove_item(self, item):
        self.items.remove(item)

    def get_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return self.discount.calculate_discount(total_price)

In this fixed example, we refactored the get_total_price() method to use the Discount strategy class, and the Discount strategy class defines a common interface that can be used to extend any type of discount. TenPercentDiscount is a subclass of Discount that implements a 10% discount calculation. The ShoppingCartWithDiscount class holds a Discount instance and calls the calculate_discount() method when calculating the total price. This way, when we need to add a different type of discount, we only need to create a new strategy class and pass it to ShoppingCartWithDiscount, without modifying any existing code.

  1. Liskov Substitution Principle (LSP)

All references to the base class must be able to use objects of its subtypes transparently. In other words, a subclass should be able to replace its parent class and not break the system's correctness.

The following example violates LSP because the inheritance relationship between the Rectangle and Square classes is problematic. Square inherits from Rectangle, but its set_height() and set_width() methods allow it to change the length of both sides, violating the definition of Rectangle, as Rectangle can have different lengths on both sides. This means that in code using Rectangle class objects, Square objects cannot be used correctly to replace them, violating LSP.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Rectangle):
    def __init__(self, size):
        self.width = size
        self.height = size

    def set_width(self, width):
        self.width = width
        self.height = width

    def set_height(self, height):
        self.width = height
        self.height = height

square = Square(5)
square.set_width(10)
square.set_height(5)
print(square.area())

In simple terms, LSP requires that a subclass can replace its parent class and be used in any situation where the parent class is used. If a subclass has additional or modified methods that are not owned by the parent class, this will break the original design. In this example, the special nature of Square and its difference from Rectangle means that it cannot fully replace Rectangle, violating LSP. To fix it we can look at the following code:

class Shape:
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, size):
        self.size = size

    def set_size(self, size):
        self.size = size

    def area(self):
        return self.size ** 2

def print_area(shape):
    print(f"Area: {shape.area()}")

shapes = [Rectangle(5, 10), Square(5)]
for shape in shapes:
    print_area(shape)

In this example, both Rectangle and Square inherit from the Shape class and implement the area() method. In the print_area() function, it accepts a Shape object as an argument and calls the area() method to get its area.

This example does not violate LSP because the Square object can successfully replace the Shape object without affecting the normal operation of the program. In other words, the Square object has the same behavior as the Shape object and can be replaced by the Shape object, so it complies with the Liskov Substitution Principle.

  1. Interface Segregation Principle (ISP)

Client should not be forced to depend on methods it does not use. Interfaces should be split into smaller and more specific parts so that clients only need to know about the methods they use.

See the following code that violates ISP:

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

    def fax(self, document):
        pass

    def scan(self, document):
        pass

class MultiFunctionPrinter(Machine):
    def print(self, document):
        print("Printing")

    def fax(self, document):
        print("Faxing")

    def scan(self, document):
        print("Scanning")

The above code, Machine is an interface for a machine, which includes the print, fax and scan methods. And MultiFunctionPrinter is a multi-function printer that inherits the Machine interface and implements all methods. This code violates ISP because not all machines need to implement the fax and scan methods, and MultiFunctionPrinter forces the implementation of these two methods. This design is redundant for other machines that only need to implement the print method, and it makes the interface unclear.

To fix this problem, we can look at the following code:

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

class Fax:
    def fax(self, document):
        pass

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

class MultiFunctionDevice(Printer, Fax, Scanner):
    def print(self, document):
        print("Printing")

    def fax(self, document):
        print("Faxing")

    def scan(self, document):
        print("Scanning")

In this new design, we split the original Machine interface into three independent interfaces Printer, Fax and Scanner. MultiFunctionDevice is a device with multiple functions, which implements the Printer, Fax and Scanner interfaces. This design allows each interface to contain only the necessary methods, and allows devices to implement the interfaces they need. This design is more in line with ISP.

  1. Dependency Inversion Principle (DIP)

High level modules should not depend on low level modules. Both should depend on abstractions. In other words, high level modules and low level modules should interact through interfaces or abstract classes. This can reduce the direct coupling between classes and improve the flexibility and reusability of the code.

Let's look at an example that violates DIP:

class Logger:
    def log(self, message):
        print(f"Log: {message}")

class UserService:
    def __init__(self):
        self.logger = Logger()

    def register(self, username, password):
        try:
            # register user to database
            print(f"User {username} registered successfully")
            self.logger.log(f"User {username} registered successfully")
        except Exception as e:
            print(f"Error: {e}")
            self.logger.log(f"Error: {e}")

In this example, UserService directly creates a Logger object. This design violates the dependency inversion principle because the high-level module UserService directly depends on the low-level module Logger.

The following code fixes the problem:

from abc import ABC, abstractmethod

class Logger(ABC):
    @abstractmethod
    def log(self, message):
        pass

class ConsoleLogger(Logger):
    def log(self, message):
        print(f"Log: {message}")

class UserService:
    def __init__(self, logger: Logger):
        self.logger = logger

    def register(self, username, password):
        try:
            # register user to database
            print(f"User {username} registered successfully")
            self.logger.log(f"User {username} registered successfully")
        except Exception as e:
            print(f"Error: {e}")
            self.logger.log(f"Error: {e}")

logger = ConsoleLogger()
service = UserService(logger)

In this example, UserService now only depends on the abstract interface of Logger instead of directly depending on the actual Logger object. This modification complies with the dependency inversion principle.

💡 What is a high-level module and what is a low-level module? In a software system, we often divide the system into different levels, such as data access layer, business logic layer, and interface layer. The data access layer may contain some code that communicates with the database, and the business logic layer uses the methods provided by the data access layer to operate the data. In this case, the business logic layer can be viewed as a high-level module because it uses the services of the low-level module.

☕️ Support Us
Your support will help us to continue to provide quality content.👉 Buy Me a Coffee