SOLID Design Principles
Tags: programming, python
Published: 2023-05-15 10:32:34

The **SOLID** Principles are five principles of Object-Oriented class design. They are a set of rules and best practices to follow while designing a class structure. These principles encourage us to create more maintainable, understandable, and flexible software.

## SOLID stands for five key design principles:

- ### **Single responsibility principle**

    A class should have one and only one reason to change, meaning that a class should have only one job.

- ### **Open-closed principle**

    Objects or entities should be open for extension but closed for modification, meaning that a class should be easily extendable without modifying the class itself.

- ### **Liskov substitution principle**

    Every subclass or derived class should be substitutable for their base or parent class, meaning that a subclass should override the parent class methods in a way that does not break functionality or cause errors.

- ### **Interface segregation principle**

    A client should never be forced to implement an interface that it doesn’t use or clients shouldn’t be forced to depend on methods they do not use, meaning that interfaces should be fine-grained and specific rather than coarse-grained and generic.

- ### **Dependency inversion principle**

    Entities must depend on abstractions, not on concretions, meaning that the high-level module must not depend on the low-level module, but they should depend on abstractions.

## Benefits of each principle

| **Principle**         | **Description**                                           | **Benefits**                                                |
|-----------------------|-----------------------------------------------------------|-------------------------------------------------------------|
| Single responsibility | A class should have only one job                          | Easier testing, lower coupling, better organization         |
| Open-closed           | A class should be extendable without modification         | More flexibility, less code duplication, easier maintenance |
| Liskov substitution   | A subclass should be substitutable for its parent class   | More consistency, better code reuse       |
| Interface segregation | Interfaces should be specific and not generic             | Less coupling, more cohesion, easier testing                |
| Dependency inversion  | High-level modules should not depend on low-level modules | More modularity, more testability, more extensibility       |

<br>

---

<br>

## Explanation with examples

- ### **Single responsibility principle**

    For example, let’s look at a class that handles both database operations and logging:

    ```python
    class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary
    
    #database operations
    def save(self):
        #save employee to database
    
    def delete(self):
        #delete employee from database
    
    #logging operations
    def log_error(self, message):
        #log error message
    
    def log_info(self, message):
        #log info message
    ```

    This class violates the single responsibility principle because it has more than one reason to change: it could change due to changes in the database logic or due to changes in the logging logic. A better approach would be to separate these responsibilities into different classes:

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

    class EmployeeRepository:
        #database operations
        def save(self, employee):
            #save employee to database
    
        def delete(self, employee):
            #delete employee from database

    class EmployeeLogger:
        #logging operations
        def log_error(self, message):
            #log error message
    
        def log_info(self, message):
            #log info message
    ```

    Now we have three classes, each with a single responsibility. This makes them easier to test, maintain and reuse.

- ### **Open-closed principle**

    Let's understand this with an example:

    ```python
    class Person:
        def __init__(self, name):
            self.name = name

        def __repr__(self):
            return f'Person(name={self.name})'


    class PersonStorage:
        def save_to_database(self, person):
            print(f'Save the {person} to database')

        def save_to_json(self, person):
            print(f'Save the {person} to a JSON file')


    if __name__ == '__main__':
        person = Person('John Doe')
        storage = PersonStorage()
        storage.save_to_database(person)
    ```

    In this example, the PersonStorage class has two methods:

    - The save_to_database() method saves a person to the database.
    
    - The save_to_json() method saves a person to a JSON file.

    Later, if we want to save the Person’s object into a file, we must modify the PersonStorage class. It means that the PersonStorage class is not open for extension but modification. Hence, it violates the open-closed principle.

    To make the PersonStorage class conforms with the open-closed principle; we need to design the classes so that when we need to save the Person’s object into a different file format, we don’t need to modify it.

    First, define the PersonStorage abstract class that contains the save() abstract method:

    ```python
    from abc import ABC, abstractmethod

    class PersonStorage(ABC):
        @abstractmethod
        def save(self, person):
            pass
    ```

    Second, create two classes PersonDB and PersonJSON that save the Person object into the database and JSON file. These classes inherit from the PersonStorage class:

    ```python
    class PersonDB(PersonStorage):
        def save(self, person):
            print(f'Save the {person} to database')


    class PersonJSON(PersonStorage):
        def save(self, person):
            print(f'Save the {person} to a JSON file')
    ```

    To save the Person object into an XML file, we can define a new class PersonXML that inherits from the PersonStorage class like this:

    ```python
    class PersonXML(PersonStorage):
        def save(self, person):
            print(f'Save the {person} to an XML file')
    ```

    And we can save the Person‘s object into an XML file using the PersonXML class:

    ```python
    if __name__ == '__main__':
        person = Person('John Doe')
        storage = PersonXML()
        storage.save(person)
    ```

- ### **Liskov substitution principle**

    Consider the following example:

    ```python
    from abc import ABC, abstractmethod

    class Notification(ABC):
        @abstractmethod
        def notify(self, message, email):
            pass

    class Email(Notification):
        def notify(self, message, email):
            print(f'Send {message} to {email}')

    class SMS(Notification):
        def notify(self, message, phone):
            print(f'Send {message} to {phone}')

    if __name__ == '__main__':
        notification = SMS()
        notification.notify('Hello', 'john@test.com')
    ```

    In this example, we have three classes: Notification, Email, and SMS. The Email and SMS classes inherit from the Notification class.

    The Notification abstract class has notify() method that sends a message to an email address.

    The notify() method of the Email class sends a message to an email, which is fine.

    However, the SMS class uses a phone number, not an email, for sending a message. Therefore, we need to change the signature of the notify() method of the SMS class to accept a phone number instead of an email.

    The following NotificationManager class uses the Notification object to send a message to a Contact:

    ```python
    class Contact:
        def __init__(self, name, email, phone):
            self.name = name
            self.email = email
            self.phone = phone


    class NotificationManager:
        def __init__(self, notification, contact):
            self.contact = contact
            self.notification = notification

        def send(self, message):
            if isinstance(self.notification, Email):
                self.notification.notify(message, contact.email)
            elif isinstance(self.notification, SMS):
                self.notification.notify(message, contact.phone)
            else:
                raise Exception('The notification is not supported')


    if __name__ == '__main__':
        contact = Contact('John Doe', 'john@test.com', '(408)-888-9999')
        notification_manager = NotificationManager(SMS(), contact)
        notification_manager.send('Hello John')
    ```

    The send() method of the NoticationManager class accepts a notification object. It checks whether the notification is an instance of the Email or SMS and passes the email and phone of contact to the notify() method respectively.

    Conform with the Liskov substitution principle
    
    First, redefine the notify() method of the Notification class so that it doesn’t include the email parameter:

    ```python
    class Notification(ABC):
        @abstractmethod
        def notify(self, message):
            pass
    ```

    Second, add the email parameter to the __init__ method of the Email class:

    ```python
    class Email(Notification):
        def __init__(self, email):
            self.email = email

        def notify(self, message):
            print(f'Send "{message}" to {self.email}')
    ```

    Third, add the phone parameter to the __init__ method of the SMS class:

    ```python
    class SMS(Notification):
        def __init__(self, phone):
            self.phone = phone

        def notify(self, message):
            print(f'Send "{message}" to {self.phone}')
    ```

    Fourth, change the NotificationManager class:

    ```python
    class NotificationManager:
        def __init__(self, notification):
            self.notification = notification

        def send(self, message):
            self.notification.notify(message)
    ```

- ### **Interface segregation principle**

    Consider the following example:

    First, define a Vehicle abstract class that has two abstract methods, go() and fly():

    ```python
    from abc import ABC, abstractmethod


    class Vehicle(ABC):
        @abstractmethod
        def go(self):
            pass

        @abstractmethod
        def fly(self):
            pass
    ```

    Second, define the Aircraft class that inherits from the Vehicle class and implement both go() and fly() methods:

    ```python
    class Aircraft(Vehicle):
        def go(self):
            print("Taxiing")

        def fly(self):
            print("Flying")
    ```

    Third, define the Car class that inherits from the Vehicle class. Since a car cannot fly, we raise an exception in the fly() method:

    ```python
    class Car(Vehicle):
        def go(self):
            print("Going")

        def fly(self):
            raise Exception('The car cannot fly')
    ```

    In this design the Car class must implement the fly() method from the Vehicle class that the Car class doesn’t use. Therefore, this design violates the interface segregation principle.

    To fix this, we need to split the Vehicle class into small ones and inherits from these classes from the Aircraft and Car classes:

    First, split the Vehicle interface into two smaller interfaces: Movable and Flyable, and inherits the Movable class from the Flyable class:

    ```python
    class Movable(ABC):
        @abstractmethod
        def go(self):
            pass

    class Flyable(Movable):
        @abstractmethod
        def fly(self):
            pass
    ```

    Second, inherits from the Flyable class from the Aircraft class:

    ```python
    class Aircraft(Flyable):
        def go(self):
            print("Taxiing")

        def fly(self):
            print("Flying")
    ```

    Third, inherit the Movable class from the Car class:

    ```python
    class Car(Movable):
        def go(self):
            print("Going")
    ```

    In this design, the Car only need to implement the go() method that it needs. It doesn’t need to implement the fly() method that it doesn’t use.

- ### **Dependency inversion principle**

    ```python
    class FXConverter:
        def convert(self, from_currency, to_currency, amount):
            print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
            return amount * 1.2

    class App:
        def start(self):
            converter = FXConverter()
            converter.convert('EUR', 'USD', 100)

    if __name__ == '__main__':
        app = App()
        app.start()
    ```

    In this example, we have two classes FXConverter and App.

    The FXConverter class uses an API from an imaginary FX third-party to convert an amount from one currency to another. For simplicity, we hardcoded the exchange rate as 1.2. In practice, we will need to make an API call to get the exchange rate.

    The App class has a start() method that uses an instance of the FXconverter class to convert 100 EUR to USD.

    The App is a high-level module. However, The App depends heavily on the FXConverter class that is dependent on the FX’s API.

    In the future, if the FX’s API changes, it’ll break the code. Also, if we want to use a different API, we’ll need to change the App class.

    To prevent this, we need to invert the dependency so that the FXConverter class needs to adapt to the App class.

    To do that, we define an interface and make the App dependent on it instead of FXConverter class. And then we change the FXConverter to comply with the interface.

    First, define an abstract class CurrencyConverter that acts as an interface. The CurrencyConverter class has the convert() method that all of its subclasses must implement:

    ```python
    from abc import ABC

    class CurrencyConverter(ABC):
        def convert(self, from_currency, to_currency, amount) -> float:
            pass
    ```

    Second, redefine the FXConverter class so that it inherits from the CurrencyConverter class and implement the convert() method:

    ```python
    class FXConverter(CurrencyConverter):
        def convert(self, from_currency, to_currency, amount) -> float:
            print('Converting currency using FX API')
            print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
            return amount * 2
    ```

    Third, add the __init__ method to the App class and initialize the CurrencyConverter‘s object:

    ```python
    class App:
        def __init__(self, converter: CurrencyConverter):
            self.converter = converter

        def start(self):
            self.converter.convert('EUR', 'USD', 100)
    ```

    Now, the App class depends on the CurrencyConverter interface, not the FXConverter class.

    The following creates an instance of the FXConverter and pass it to the App:

    ```python
    if __name__ == '__main__':
        converter = FXConverter()
        app = App(converter)
        app.start()
    ```

    In the future, we can support another currency converter API by subclassing the CurrencyConverter class. For example, the following defines the AlphaConverter class that inherits from the CurrencyConverter.

    ```python
    class AlphaConverter(CurrencyConverter):
        def convert(self, from_currency, to_currency, amount) -> float:
            print('Converting currency using Alpha API')
            print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
            return amount * 1.15
    ```

    Since the AlphaConvert class inherits from the CurrencyConverter class, you can use its object in the App class without changing the App class:

    ```python
    if __name__ == '__main__':
        converter = AlphaConverter()
        app = App(converter)
        app.start()
    ```

<br>

---

<br>

## References

- https://www.freecodecamp.org/news/solid-principles-explained-in-plain-english/

- https://www.baeldung.com/solid-principles

- https://www.pythontutorial.net/python-oop/python-open-closed-principle/

- https://medium.com/@alex24dutertre/square-rectangle-and-the-liskov-substitution-principle-ee1eb8433106

- https://www.bmc.com/blogs/solid-design-principles/

<br>

---