DEV Community

Cover image for SOLID Principles - Breaking Bad using Python 🧪
Damian J☁️
Damian J☁️

Posted on

SOLID Principles - Breaking Bad using Python 🧪

"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:

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!

Enter fullscreen mode Exit fullscreen mode

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.

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!
Enter fullscreen mode Exit fullscreen mode

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.

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")
Enter fullscreen mode Exit fullscreen mode

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:

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")
Enter fullscreen mode Exit fullscreen mode

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:

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")
Enter fullscreen mode Exit fullscreen mode

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.

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)
Enter fullscreen mode Exit fullscreen mode

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:

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

    @abstractmethod
    def deliver(self, _production):
        pass
Enter fullscreen mode Exit fullscreen mode

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

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

class DeliveryOperation(Operations)
    @abstractmethod
    def deliver(self, _production):
        pass
Enter fullscreen mode Exit fullscreen mode

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.

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)
Enter fullscreen mode Exit fullscreen mode

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.

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"

Enter fullscreen mode Exit fullscreen mode

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.

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"

Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)