DEV Community

Sagar Dutta
Sagar Dutta

Posted on

SOLID Design Principles

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

Explanation with examples

  • Single responsibility principle

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

    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:

    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:

    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:

    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:

    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:

    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:

    if __name__ == '__main__':
        person = Person('John Doe')
        storage = PersonXML()
        storage.save(person)
    
  • Liskov substitution principle

    Consider the following example:

    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:

    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:

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

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

    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:

    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:

    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():

    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:

    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:

    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:

    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:

    class Aircraft(Flyable):
        def go(self):
            print("Taxiing")
    
        def fly(self):
            print("Flying")
    

    Third, inherit the Movable class from the Car class:

    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

    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:

    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:

    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:

    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:

    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.

    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:

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

References


Top comments (0)