Let's talk SOLID in Python

You might have heard the term SOLID once or twice in your programming life. You probably even implemented the principles of SOLID in your projects without knowing it! Today we will learn what SOLID is, and why is it so important to make your projects great. Let’s start what the acronym:

S - Single-responsiblity principle
O - Open-closed principle
L - Liskov substitution principle
I - Interface segregation principle
D - Dependency Inversion Principle

Fancy names, time to see what they are all about!

Single-responsiblity principle (SRP)

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

Think of a class as a functionality in our system. As systems grow these functionalities can change (depending on customer needs). If you have more than one functionality per class, you get more reasons to change, since the functionalities are coupled in that class. This is a bad thing, because changing one functionality can break the other. We don’t want that.

Below you have an example class that doesn’t conform to SRP:

class Student:
    def avg_grades(self):
        ...
    
    def sum_missed(self):
        ...
    
    def save(self):
        ...

As you can see, this class does three different things based on business logic:

To conform to the SRP, we need to divide those, so that a class has only one responsibility.

class StudentGradesReport():
    def avg_grades(self, student):
        ...


class StudentAbsenceReport():
    def sum_missed(self, student):
        ...


class StudentDb():
    def save(self, student):
        ...

Open-closed principle (OCP)

Objects or entities should be open for extension, but closed for modification.

You write code that implements some functionalities. After some time, the client requests some new functionalities. If you need to change existing code to make it work - you probably broke the Open-closed principle. Ideally new functionalities should mean new code. This principle was added, because each change of existing code is prone to bugs and can break already working things.

To show an example - we have an application that calculates tax for specific market products. We could implement it like this:

class ProductType(Enum):
    APPLE = auto()
    SUGAR = auto()
    ALCOHOL = auto()


@dataclass
class Product:
    product_type: str
    price: float


def calculate_tax_price(product):
    if product.product_type == ProductType.APPLE:
        return product.price + product.price * 0.05
    elif product.product_type == ProductType.SUGAR:
        return product.price + product.price * 0.08
    elif product.product_type == ProductType.ALCOHOL:
        return product.price + product.price * 0.23

Not only this looks ugly, but also adding new products would mean that we need to change existing code - hence breaking the OCP. We can fix this by adding some abstraction to our code, and allowing easy extension:

@dataclass
class Product(ABC):
    price: float

    @abstractmethod
    def calculate_tax_price(self):
        pass


class Apple(Product):
    product_type = ProducType.APPLE

    def calculate_tax_price(self):
        return self.price + self.price * 0.05


class Sugar(Product):
    product_type = ProducType.SUGAR

    def calculate_tax_price(self):
        return self.price + self.price * 0.08


class Alcohol(Product):
    product_type = ProducType.ALCOHOL

    def calculate_tax_price(self):
        return self.price + self.price * 0.23

Now, when a new product appears, we won’t need to change any existing code, so the OCP is OK.

Liskov substitution principle (LSP)

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

Scary sounding definition, for an easy principle. In practice, this principle states that whenever we inherit from a class, we should also be able to inherit from classes that inherit from that class.

Best way to see it, is through this great example I found on the web. First a bad example (not LSP):

class NormalFile:
    def read(self):
        ...
        print('Reading from regular file')
 
    def write(self, input_text):
        ...
        print('Writing to regular file')
 
 
class ReadonlyFile(NormalFile):
    def read(self):
        ...
        print('Reading from readonly file')
 
    def write(self):
        raise Exception('Can\'t write to readonly file')
 
 
normal_file = NormalFile()
readonly_file = ReadonlyFile()
 
 
def make_file_operations(fil, input_text):
    if not isinstance(fil, ReadonlyFile):
        fil.write(input_text)
    fil.read()
 
make_file_operations(normal_file, 'Bananas are great')
make_file_operations(readonly_file, 'Bananas are great')

OUTPUT:

Writing to regular file
Reading from regular file
Reading from readonly file

Thi works but doesn’t conform to LSP. Not only the write function has different parameters, but also it behaves in a different way. We need to fix this to conform with LSP, and we can do it this way:

class ReadableFile(ABC):
    @abstractmethod
    def read(self) -> str:
        ...
 
 
class WritableFile(ABC):
    @abstractmethod
    def write(self, input_text: str) -> None:
        ...
 
 
class NormalFile(ReadableFile, WritableFile):
    def read(self) -> str:
        ...
        print('Reading from file')
 
    def write(self, input_text: str) -> None:
        ...
        print('Writing to file')
 
 
class ReadonlyFile(ReadableFile):
    def read(self) -> str:
        ...
        print('Reading from readonly file')

This way we conform to LSP, while keeping the code clean. Moreover we get some clean code, so it’s easier to read through. Moreover we exactly know what to expect from parameters and what the return type should be.

Interface segregation principle (ISP)

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.

Although Python doesn’t have an interface structure like other programming languages, we can still implement this principle using abstract methods (kind of). Still, it is good to understand ISP, so let’s get to it (even if we don’t use it).

So what is an interface? An interface defines what methods NEED to be implemented in a class that inherits from it, but doesn’t describe how - this should be done by the class. So the Interface segreagation principle tells us that when a class inherits from an interface, but doesn’t need all of its methods, we shouldn’t use that interface for that class. Don’t force classes to implement methods they don’t need. Pretty simple.

Dependency Inversion Principle (DIP)

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

Dependency Inversion Principle helps us create more elastic code. When working with badly written projects we can approach problems like:

To understand this problem, we can think of a message sending problem:

class Task:
    def process(self) -> None:
        ...
        email_sender = EmailSender()
        email_sender.send('some nice message')
 
 
class EmailSender:
    def send(self, message: str) -> None:
        ...
        print(f'Sending email with message: {message}')

As you can see above, we have a Task class that is dependant on the EmailSender class. One of the problems we can encounter, is the amount of changes that we would need to make, if we wanted to change the type of the sender (use a different service). To avoid this problem, we can add a layer of abstraction, that will handle what service should be used.

class MessageSender(ABC):
    @abstractmethod
    def send(self, message: str) -> None:
        ...
 
 
class EmailSender(MessageSender):
    def send(self, message: str) -> None:
        ...
        print(f'Sending email with message: {message}')
 
 
class Task:
    def __init__(self, message_sender: MessageSender):
        self.message_sender = message_sender
 
    def process(self) -> None:
        self.message_sender.send('some nice message')

Now, the Task doesn’t care what sender is used, as it accepts anything that is based on the MessageSender abstract class. Now we give the class what it needs, instead of the class deciding on that matter. That is the purpose of the dependency inversion. We need to understand where the dependency should be, not to create code that is hard to change.


Tell me what you think!