SOLID Principles - Breaking Bad using Python 🧪
Tags: beginners, python, programming, codenewbie
Published: 2023-04-13 19:14:57


> _"I've got a job for you. It's complicated, and there's no guarantee you'll come back alive. But it'll be the most exciting thing you'll ever do." - Saul Goodman_

At some point in our dev adventure we'll come across a set of guidelines, patters and ani-patterns, especially when it comes to refactoring. That's why we gotta start with the basics - **The SOLID Principles**. They're like the purest form of _crystal_, keeping our code clean and **DRY (Don't Repeat Yourself)**. With SOLID guidelines, our software development becomes much more modular, scalable, maintainable, testable, reusable, you name it! 

You know what they say:

> _"I am the one who ~~knocks~~ **knows SOLID principles**" - not Walter White_

If you want to be a good _cook_ (aka developer), you need to learn how to write code that's able to do all those things. So, start with SOLID, and you build from there.

> _"Yeah, science!" - Jesse Pinkman_


---


Lets explain what SOLID principles are, and how to apply them using Python.

**Single Responsibility Principle (aka SRP)**

> _"A class should have only one reason to change" - Robert C. Martin_

A class, method or module should have one responsibility or job to do, and any change to that responsibility should only require changes to that class/method. 

This also means that we're creating a _high-cohesion responsibility_ ensuring that the function does one thing only, and can be reused easily.

_🧪 Walt is responsible for producing crystal, while Jesse is responsible for distributing it._

Let's refactor the `Production` class and apply SRP:

```python
class Production:
    def __init__(self):
        self.batch = []
        self.sizes = []
        self.costs = []
        self.status = "preparing"

    def create(self, id, size, cost):
        self.batch.append(id)
        self.sizes.append(size)
        self.costs.append(cost)

    def total_cost(self):
        total = 0
        for i in range(len(self.costs)):
            total += self.sizes[i] * self.costs[i]
        return total

    def operation(self, operation_type, person):
        if operation_type == "manufacturing":
            print(f"Is {person} able to do this?")
            print(f"{person} is manufacturing...")
            self.status = "cooking"
        elif operation_type == "distribution":
            print(f"Is {person} able to do this?")
            print(f"{person} is distributing...")
            self.status = "selling"
        else:
            print(f"Operation: {operation_type} is not supported")
            self.status = "failed"

production = Production()
production.operation("distribution", "Jesse") # Jesse's on the move!

```

Looking at the code above, we have a `Production` class with `create` method to create our batches, `total_cost` method to calculate the total cost of each batch, and `operation` method that is responsible for __manufacturing__ and __distribution__. We're looking at refactoring the `operation` method as it shouldn't really belong to the `Production`.

The trick here is to remove the `if-else` condition and create new class to handle operation giving it a Single Responsibility.

```python
class Production:
    def __init__(self):
        self.batch = []
        self.sizes = []
        self.costs = []
        self.status = "preparing"

    def create(self, id, size, cost):
        self.batch.append(id)
        self.sizes.append(size)
        self.costs.append(cost)

    def total_cost(self):
        total = 0
        for i in range(len(self.costs)):
            total += self.sizes[i] * self.costs[i]
        return total

class Operations:
    def manufacturing(self, _production, person):
        print(f"{person} is manufacturing...")
        _production.status = "cooking"

    def distribution(self, _production, person):
        print(f"{person} is distributing...")
        _production.status = "selling"


production = Production()

operations = Operations()
operations.manufacturing(production, "Jesse Pinkman")  # Jesse's on the move!
```
Now we have _de-coupled_ `Operations` functionality from `Production`. Note how we're passing a `production` variable class into the `operations` to be able to update the `self.status`.


---


**Open-Closed Principle (aka OCP)**

> _"I am the one who knocks!" - Walter White_

Software entities (such as classes, methods, functions, etc.) should be open for extension but closed for modification. 

This means that we should design our software in a way that allows us to add new functionality, or behaviour without changing the existing code. We can do this with the use of _composition, inheritance and interfaces_ by *abstracting structure* of classes (superclass), and subclasses.

_🧪 When Walter and Jesse start working with a new distributor, they don't need to change the way they produce their crystal. They simply add a new distributor to their network._

```python
class Production:
    def __init__(self):
        self.batch = []
        self.sizes = []
        self.costs = []
        self.status = "preparing"
    ...

class Operations:
    def manufacturing(self, _production, person):
        print(f"{person} is manufacturing...")
        _production.status = "cooking"

    def distribution(self, _production, person):
        print(f"{person} is distributing...")
        _production.status = "selling"

production = Production()

operations = Operations()
operations.manufacturing(production, "Jesse Pinkman")
```

Taking the code from above, we're going to apply **Open-Closed Principle** to the `class Operations`. Why? Because if we wanted to add another business operation (say shipment, recruitment or compliance) we would need to modify the code which violates OCP.

The solution is to create `subclasses`, and _abstracting out_ the **superclass** (the parent) with the use of `abc` module:

```python
from abc import ABC, abstractmethod

class Production:
    def __init__(self):
        self.batch = []
        self.sizes = []
        self.costs = []
        self.status = "preparing"
    ...

class Operations(ABC):
    @abstractmethod
    def process(self, _production, person):
        pass

class ManufacturingProcess(Operations):
    def process(self, _production, person):
        print(f"{person} is manufacturing...")
        _production.status = "cooking"

class DistributionProcess(Operations):
    def process(self, _production, person):
        print(f"{person} is distributing...")
        _production.status = "selling"

production = Production()

distribution = DistributionProcess()
distribution.process(production, "Jesse Pinkman")
```

So we've abstracted out the _Operations_, allowing us to inherit `process` method by the `ManufacturingProcess` and `DistributionProcess` classes. This means we can now add more _Operation_ types without modifying the existing code.

---


**Liskov Substitution Principle (aka LSP)**

This principle suggests that we should be able to replace objects with instances of their subtypes or subclasses without affecting the correctness of the program. 

This means that we should be able to substitute an object of a subclass for an object of its superclass without changing the behaviour. We can do this by creating an **"is-a"** relationship between classes, and ensuring that the subclass does not violate any of the "contracts" (set of expectations or requirements) established by the superclass.

_🧪 Remember when Jesse, takes over as the lead cook, he is able to fill the same role as Walter without any issues. Same thing for Gale or Todd, filling the role for Jesse as Walter assistants, with expectations they can do the very same job when instructed._

As you can guess, there is more to refactor! The problem we have now is that the parameter `person` is required for every single _Operation_. What if we add a new operation process that doesn't require a human, but rather, is more of an automated shipment process requiring an `id`.

> _"I'm just a problem solver." - Gus Fring_

Let's focus on `Operations` class:

```python
class Operations(ABC):
    @abstractmethod
    def process(self, _production):
        pass

class ManufacturingProcess(Operations):
    def process(self, _production, person):
        print(f"{person} is manufacturing...")
        _production.status = "cooking"

class DistributionProcess(Operations):
    def process(self, _production, person):
        print(f"{person} is distributing...")
        _production.status = "selling"

production = Production()

distribution = DistributionProcess()
distribution.process(production, "Jesse Pinkman")
```

First we need to remove the argument inheritance of `person` as it's going to be specific to the type of operation. This means we need to move it to the initializer `__init__` of our subclass.

Then we'll add a new operation subclass `InternationalShipment`, this time our initializer will be an `id` of the shipment.

```python
class Operations(ABC):
    @abstractmethod
    def process(self, _production):
        pass

class ManufacturingProcess(Operations):
    def __init__(self, person):
        self.person = person

    def process(self, _production):
        print(f"{self.person} is manufacturing...")
        _production.status = "cooking"

class DistributionProcess(Operations):
    def __init__(self, person):
        self.person = person

    def process(self, _production):
        print(f"{self.person} is distributing...")
        _production.status = "selling"

class InternationalShipment(Operations):
    def __init__(self, id):
        self.id= id

    def process(self, _production):
        print(f"ID: {self.id} accepted!")
        _production.status = "shipping"

production = Production()

distribution = DistributionProcess("Jesse Pinkman")
distribution.process(production)

shipment = InternationalShipment("YYY1")
shipment.process(production)
```

Now we've added an ability to not only add new functionality without modifying the code, but also an ability to have parameters that are specific to our subclass. Our code now passes the principle of Liskov Substitution.


---



**Interface Segregation Principle (aka ISP)**

Clients should not be forced to depend on interfaces they do not use. This means that we should split interfaces into smaller, more specific ones, so that clients only need to depend on the interfaces that they actually use.

This aims to design interfaces that need to be specific to the clients that use them. And in reverse we should not force clients to depend on methods (their superclass) or properties they don't need or use.

_🧪 As a farfetched analogy, Saul Goodman, only needs to know about the legal aspects of Heisenberg's business. He doesn't need to know anything about the chemistry or logistics of producing and distributing crystal while being exposed to the whole undertaking._

In the example above we have a superclass _Operation_ that has abstract method `process`. Now, what interface segregation suggests is that we need to only pass abstracted methods to our subclass that actually has a use case.

Let's add a new abstract method `deliver` to _Operations_:

```python
class Operations(ABC):
    @abstractmethod
    def process(self, _production):
        pass

    @abstractmethod
    def deliver(self, _production):
        pass
```

The problem with the above is that our subclasses `ManufacturingProcess` and `DistributionProcess` doesn't really need to inherit `deliver` method, as it's not part of that process. So we're going to abstract it out by creating an **interface** that extends _Operations_ superclass.

> _"No more half measures, Walter." - Mike Ehrmantraut_

```python
class Operations(ABC):
    @abstractmethod
    def process(self, _production):
        pass

class DeliveryOperation(Operations)
    @abstractmethod
    def deliver(self, _production):
        pass
```

Now we have an `DeliveryOperation` that inherits abstracted method `process` from its superclass, and additional `deliver` method that can be used inside the `InternationalShipment` subclass.

```python
class Operations(ABC):
    @abstractmethod
    def process(self, _production):
        pass

class DeliveryOperation(Operations)
    @abstractmethod
    def deliver(self, is_delivered):
        pass

class ManufacturingProcess(Operations):
    def __init__(self, person):
        self.person = person

    def process(self, _production):
        print(f"{self.person} is manufacturing...")
        _production.status = "cooking"

class DistributionProcess(Operations):
    def __init__(self, person):
        self.person = person

    def process(self, _production):
        print(f"{self.person} is distributing...")
        _production.status = "selling"

class InternationalShipment(DeliveryOperation):
    def __init__(self, id):
        self.id= id

    def deliver(self, is_delivered):
        self.is_delivered = is_delivered
        pass

    def process(self, _production):
        print(f"ID: {self.id} accepted!")
        if self.is_delivered:
            _production.status = "shipping"

production = Production()

distribution = DistributionProcess("Jesse Pinkman")
distribution.process(production)

shipment = InternationalShipment("YYY1")
shipment.deliver(True)
shipment.process(production)
```


---



**Dependency Inversion Principle (aka DIP)**

The last principle in the segment is **Dependency Inversion** that suggests that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. 

This means that we should design modules independently of one another. Instead of having a high-level module directly depend on a low-level module, we should create an abstraction that both modules can depend on. By doing this, we can reduce coupling between modules and make our code more flexible and easier to modify over time. The take away here is abstractions should not depend on details, details should depend on abstractions.

_🧪 When Walter & Jesse initially distributed crystal with low-level criminal, Krazy-8, that quickly became a liability. Walter had to quickly find a new way to distribute their product. Because Walter and Jesse were tightly coupled to Krazy-8, they are unable to adapt quickly and their operation suffered._

We're going to create a new `ShipmentVerification` class, that will be responsible for checking if our shipment has completed. We're also going to add a new `verifier` parameter to our `InternationalShipment` initializer specifying the _type_ of verification class we're using.

```python
class ShipmentVerification:
    complete = False

    def verification(self, id):
        self.complete = True

    def is_delivered(self) -> bool:
        return self.complete

...

class InternationalShipment(DeliveryOperation):
    def __init__(self, id, verifier: ShipmentVerification):
        self.id= id
        self.verifier= verifier

    def deliver(self, is_delivered):
        self.is_delivered = is_delivered
        pass

    def process(self, _production):
        print(f"ID: {self.id} accepted!")
        if self.is_delivered:
            _production.status = "shipping"

```

So what's the problem with the above, I hear you ask? Well, the `ShipmentVerification` class has a high-level dependency, not an abstraction, as **Dependency Inversion Principle** indicates. 

> _"Say my name." - Walter White_

The solution is to abstract `ShipmentVerification` class out, so that it can be used independently.

```python
class Verifier(ABC)
    @abstractmethod
    def is_delivered(self):
        pass

class ShipmentVerification(Verifier):
    complete = False

    def verification(self, id):
        self.complete = True

    def is_shipped(self) -> bool:
        return self.complete

...

class InternationalShipment(DeliveryOperation):
    def __init__(self, id, verifier: Verifier):
        self.id= id
        self.verifier = verifier

    def deliver(self, is_delivered):
        self.is_delivered = is_delivered
        pass

    def process(self, _production):
        print(f"ID: {self.id} accepted!")
        if self.is_shipped:
            _production.status = "shipping"

```


---



That is it! We've covered all the SOLID principles! I hope it all makes sense and you can smash your next interview, or apply them in your next project, making it all modular and simple _-able_. 

Also I'd like to mention that this is my very first post, and feedback (or roast🥩) is very much welcome! Cheers!