SOLID Design Principles
Published: 2023-02-20 06:46:10

SOLID Design Principles are a set of five design principles introduced by Robert C. Martin. These principles help developers build maintainable, flexible and scalable software systems.
SOLID is an acronym for the following design principles:
- **S**ingle Responsibility Principle.
- **O**pen-Closed Principle.
- **L**iskov Substitution Principle.
- **I**nterface Segregation Principle.
- **D**ependency Inversion Principle.


## Single Responsibility Principle(SRP):
SRP says that a class should have only one responsibility and should have only one reason to change.

For instance, we have a class called 'Order' that represents a customer's order at a restaurant. The Order class has two responsibilities: handling the order items and printing the receipt.
```
class Order:
    def __init__(self, order_items):
        self.order_items = order_items
        
    def calculate_total(self):
        # logic to calculate total cost of order
        pass
    
    def print_receipt(self):
        # logic to print receipt
        pass
```
The above implementation violates SRP since it has two responsibilities. We can separate these responsibilities into two classes.
```
class Order:
    def __init__(self, order_items):
        self.order_items = order_items
        
    def calculate_total(self):
        # logic to calculate total cost of order
        pass
    
class ReceiptPrinter:
    def print_receipt(self, order):
        # logic to print receipt
        pass
```
The 'Order' class is now responsible for handling the order items, and the 'ReceiptPrinter' class is responsible only for printing the receipt. This separation makes it easier to update or replace the receipt printing logic in the future without affecting the 'Order' class.


---
## Open-Closed Principle(OCP):
The OCP states "Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification".

Suppose we have an existing 'Product' class that represents a product in the store, with properties such as 'name', 'price' and 'description'.
```
class Product:
    def __init__(self, name, price, description):
        self.name = name
        self.price = price
        self.description = description
        
    def get_price(self):
        return self.price
```
Now suppose we were to add a new feature where we offer discounts on certain products. We can apply OCP here by creating a new 'DiscountProduct' subclass that inherits 'Product' class without modifying the 'Product' class.
```
class Product:
    def __init__(self, name, price, description):
        self.name = name
        self.price = price
        self.description = description
        
    def get_price(self):
        return self.price

class DiscountedProduct(Product):
    def __init__(self, name, price, description, discount):
        super().__init__(name, price, description)
        self.discount = discount
        
    def get_price(self):
        discounted_price = super().get_price() - self.discount
        return max(discounted_price, 0)
```
The 'DiscountedProduct' class overrides the 'get_price' method of the Product class to return the discounted price of the product.
```
# Usage
product = Product("Shirt", 20, "A basic shirt")
print("Product price:", product.get_price())

discounted_product = DiscountedProduct("Jacket", 100, "A warm jacket", 25)
print("Discounted product price:", discounted_product.get_price())
```
By applying the OCP in the above example, we have made our code more maintainable and flexible. If we want to add new features in the future, we can create new subclasses of 'Product' without modifying existing code. This makes our code more scalable and less prone to errors.


---
## Liskov substitution principle(LSP):
Liskov Substitution Principle states that "Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program."

Let’s take an example of class 'Car' that would indicate the type of a car. The 'PetrolCar' class inherits the 'Car' class.

```
class Car:
    def __init__(self, type):
        self.type = type

class PetrolCar(Car):
    def __init__(self, type):
        self.type = type

car = Car('SUV'):
car.properties = {'color': 'Red', 'gear': 'Auto', 'capacity': 6}
print(car.properties)

petrol_car = PetrolCar('Sedan')
petrol_car.properties = ('Blue', 'Manual', 4)
print(petrol_car.properties)

Output:
{'color': 'Red', 'gear': 'Auto', 'capacity': 6}
('Blue', 'Manual', 4)
```

In the above example, adding car properties does not follow any standard and it is upto the developers to implement it as a dictionary or tuple or other ways. 

Now, if we want to find all the red-coloured cars based on the current implementation.
```
cars = [car, petrol_car]

def find_red_cars(cars):
    red_cars = 0
    for car in cars:
        if car.properties['color'] == 'Red':
            red_cars += 1
    print(f'Number of Red Cars = {red_cars}')

find_red_cars(cars)

# TypeError: tuple indices must be integers or slices, not str
```
Here, the implementation breaks the LSP as we cannot replace Car's object with its subclass PetrolCar's object in the function written to find red-colored cars.

So how can we fix this?, we can implement getters and setters methods in the Car's class using which we can set and get the properties of the Car without leaving the decision to developers on how to implement Car's properties. 

```
class Car:
    def __init__(self, type):
        self.type = type
        self.car_properties = {}

    def set_properties(self, color, gear, capacity):
        self.car_properties = {'color': color, 'gear': gear, 'capacity': capacity}

    def get_properties(self):
        return self.car_properties

class PetrolCar(Car):
    def __init__(self, type):
        self.type = type
        self.car_properties = {}

car = Car('SUV'):
car.set_properties('Red', 'Auto', 6)

petrol_car = PetrolCar('Sedan')
petrol_car.set_properties('Blue', 'Manual', 4)

cars = [car, petrol_car]

def find_red_cars(cars):
    red_cars = 0
    for car in cars:
        if car.get_properties()['color'] == 'Red':
            red_cars += 1
    print(f'Number of Red Cars = {red_cars}')

find_red_cars(cars)

Output: Number of Red Cars = 1
```

---
## Interface Segregation Principle(ISP):
The ISP states that clients should not be forced to depend on methods they do not use. In other words, it is better to have multiple small interfaces, each serving a specific purpose, rather than a single large interface with many methods.
Suppose we have a Communicator interface that includes methods for making calls, sending messages, and browsing the internet.

Now, if we want to implement a Landline phone which is a communication device, we create a new class LandlinePhone using the same CommunicationDevice interface. This is exactly when we face the problem due to a large CommunicationDevice interface we created. In the class LanlinePhone, we implement the make_calls() method, but as we also inherit abstract methods send_sms() and browse_internet() we have to provide an implementation of these two abstract methods also in the LandlinePhone class even if these are not applicable to this class LandlinePhone. We can either throw an exception or just write pass in the implementation, but we still need to provide an implementation. 
```
from abc import ABC, abstractmethod

class Communicator(ABC):
    @abstractmethod
    def make_call(self, number):
        pass

    @abstractmethod
    def send_message(self, number, message):
        pass

    @abstractmethod
    def browse_internet(self):
        pass

class SmartPhone(CommunicationDevice):
  def make_calls(self, number):
    #implementation
    pass

  def send_sms(self, number, message):
    #implementation
    pass

  def browse_internet(self):
    #implementation
    pass

  class LandlinePhone(CommunicationDevice):
    def make_calls(self, number):
      #implementation
      pass

    def send_sms(self, number, message):
      #just pass or raise exception as this feature is not supported 
      pass

    def browse_internet(self):
      #just pass or raise exception as this feature is not supported
      pass 
```
Instead of creating a large interface, we can create a smaller interfaces for each method.
```
from abc import ABC, abstractmethod

class CallingDevice:
  @abstractmethod
  def make_calls(self, number):
    pass

class MessagingDevice:
  @abstractmethod
  def send_sms(self, number, message):
    pass

class InternetbrowsingDevice:
  @abstractmethod
  def browse_internet(self):
    pass

class SmartPhone(CallingDevice, MessagingDevice, InternetbrowsingDevice):
  def make_calls(self, number):
    #implementation
    pass

  def send_sms(self, number, message):
    #implementation
    pass

  def browse_internet(self):
    #implementation
    pass

class LandlinePhone(CallingDevice):
  def make_calls(self, number):
    #implementation
    pass
```

---
## Dependency Inversion Principle(DIP):
The Dependency Inversion Principle states that:
- High level module should not depend on low level modules. Both should depend on abstractions

- Abstractions should not depend on details. Details should depend on abstractions.

```
class LightSwitch:
    def __init__(self, light):
        self.light = light
        
    def turn_on(self):
        self.light.turn_on()
        
    def turn_off(self):
        self.light.turn_off()

class Light:
    def turn_on(self):
        # Code to turn on the light
        
    def turn_off(self):
        # Code to turn off the light
```
In this example, the LightSwitch class depends on the Light class to turn the light on and off. However, this violates the DIP, since the LightSwitch is a high-level module and Light is a low-level module.

To fix this, we can invert the dependency by introducing abstraction.

```
from abc import ABC, abstractmethod

class Switchable(ABC):
    @abstractmethod
    def turn_on(self):
        pass
        
    @abstractmethod
    def turn_off(self):
        pass

class LightSwitch:
    def __init__(self, switchable):
        self.switchable = switchable
        
    def turn_on(self):
        self.switchable.turn_on()
        
    def turn_off(self):
        self.switchable.turn_off()

class Light(Switchable):
    def turn_on(self):
        # Code to turn on the light
        
    def turn_off(self):
        # Code to turn off the light
```
Now, the 'LightSwitch' class relies on the 'Switchable' interface rather than the 'Light' class directly. This ensures that the high-level module ('LightSwitch') depends on an abstraction('Switchable'), rather than low-level module('Light') directly.

---
## References:
- [SOLID Design Principles with Python Examples, Hiral Amodia](https://www.linkedin.com/pulse/solid-design-principles-python-examples-hiral-amodia).

- [SOLID, wikipedia](https://en.wikipedia.org/wiki/SOLID)

- [SOLID Principles Made Easy, sudoCODE](https://www.youtube.com/playlist?list=PLTCrU9sGybuq3Jz51xfT3mA2BIVNDHwIV)