SOLID Principles tutorial for python lovers

fayomihorace

Horace FAYOMI

Posted on March 5, 2023

SOLID Principles tutorial for python lovers

SOLID principles are five essential rules for designing a great class structure in Object-Oriented programming.
They are a set of guidelines to follow for creating well-organized and maintainable code.
In this article, you'll learn everything you need to know to use SOLID principles illustrated using python.

We will start with a basic design in python that breaks those principles and we’ll improve it step by step.

Let's get started!


This tutorial is available in video format here.


SOLID stands for:

  • Single Responsibility
  • Opened to extend and closed to change
  • Liskov substitution
  • Interface segregation
  • Dependency inversion

Below we have a simple code that calls chatGPT API to generate a birthday message for someone and send that message to the person on his WhatsApp phone number.

class AIBirthdayMessageSender:
    """Handle the feature of sending a birthday message to someone."""

    def __init__(self):
        self.message = ''

    def generate_message(self, receiver_name: str):
        print("Authenticate to ChatGPT API and request for birthday message")
        self.message = f"Happy birthday {receiver_name} from ChatGPT"

    def send(self, phone: str):
        print(f"Authenticate to Whatsapp API and send message to {phone}")
        print(f"message sent: ~~~{self.message}~~~")

if __name__ == "__main__":
    birthday_msg_sender = AIBirthdayMessageSender()
    birthday_msg_sender.generate_message("Johnathan Smith")
    birthday_msg_sender.send(phone="022990149224")
Enter fullscreen mode Exit fullscreen mode

For simplicity, we've just printed what the code is supposed to do instead of the real code.


1. Single Responsibility

This principle states that a class should have one and only one task.
If you run the code you'll see this output:

Authenticate to ChatGPT API and request for birthday message
Authenticate to Whatsapp API and send message to 022990149224
message sent: ~~~Happy Birthday Johnathan Smith from ChatGPT~~~
Enter fullscreen mode Exit fullscreen mode

So it looks well.

But, there is an issue. The class AIBirthdayMessageSender, as its names suggest should only be responsible
for sending the message. But as we can see it also handle the message generation from chatGPT. So it does two unrelated things which break the Single responsibility principle.

To fix that we can move the functionality of AI message generation into another class:

class ChatGptBirthdayMsgGenerator:
    """Generate a birthday message from chat GPT."""

    def __init__(self, receiver_name: str):
        self.receiver_name = receiver_name

    def generate(self):
        print("Authenticate to ChatGPT API and request for birthday message")
        return f"Happy Birthday {self.receiver_name} from ChatGPT"


class AIBirthdayMessageSender:
    """Handle the feature of sending a birthday message to someone."""

    def __init__(self, message: str):
        self.message = message

    def send(self, phone: str):
        print(f"Authenticate to Whatsapp API and send message to {phone}")
        print(f"message sent: ~~~{self.message}~~~")


if __name__ == "__main__":
    msg_generator = ChatGptBirthdayMsgGenerator(receiver_name="Johnathan Smith")
    message = msg_generator.generate()
    birthday_msg_sender = AIBirthdayMessageSender(message)
    birthday_msg_sender.send(phone="022990149224")
Enter fullscreen mode Exit fullscreen mode

And if you run, it should still work.


2. Opened to extend and closed to change

This principle states that we don't need to modify existing code before being able to repeate a behavior.

Let's say in the future we want to be able to send the message via email, or to a Facebook account instead of to a WhatsApp number.
We will be obliged to modify AIBirthdayMessageSender like this probably:

class AIBirthdayMessageSender:

    def send_phone(self, phone: str):
        pass

    def send_email(self, email: str):
        pass

    def send_facebook(self, username: str):
        pass
Enter fullscreen mode Exit fullscreen mode

But by doing that we break the Opened/close principle because we should have written our class from in the way
that we don't need to modify the existing class if we want to add a new feature, but we just need to extend them.

We can fix it by using inheritance:

from abc import ABC, abstractmethod

class ChatGptBirthdayMsgGenerator:
    """Generate a birthday message from chat GPT."""

    def __init__(self, receiver_name: str):
        self.receiver_name = receiver_name

    def generate(self):
        print("Authenticate to ChatGPT API and request for birthday message")
        return f"Happy Birthday {self.receiver_name} from ChatGPT"

class AIBirthdayMessageSender(ABC):

    def __init__(self, message: str):
        self.message = message

    @abstractmethod
    def send(self, phone: str):
        pass

class WhatsAppBirthdayMessageSender(AIBirthdayMessageSender):
    """Handle the feature of sending a birthday message to someone."""

    def send(self, phone: str):
        print(f"Authenticate to Whatsapp API and send message to {phone}")
        print(f"message sent: ~~~{self.message}~~~")

class EmailBirthdayMessageSender(AIBirthdayMessageSender):
    """Handle the feature of sending a birthday message to someone."""

    def send(self, email: str):
        print(f"Authenticate to Email API and send mail to {email}")
        print(f"message sent: ~~~{self.message}~~~")

if __name__ == "__main__":
    msg_generator = ChatGptBirthdayMsgGenerator(receiver_name="Johnathan Smith")
    message = msg_generator.generate()

    whatsapp_sender = WhatsAppBirthdayMessageSender(message)
    whatsapp_sender.send(phone="022990149224")

    email_sender = EmailBirthdayMessageSender(message)
    email_sender.send(email="j_smith@gmail.com")
Enter fullscreen mode Exit fullscreen mode

If you run the code again, you should see:

Authenticate to ChatGPT API and request for birthday message
Authenticate to Whatsapp API and send message to 022990149224
message sent: ~~~Happy Birthday Johnathan Smith from ChatGPT~~~
Authenticate to Email API and send mail to j_smith@gmail.com
message sent: ~~~Happy Birthday Johnathan Smith from ChatGPT~~~
Enter fullscreen mode Exit fullscreen mode

It still works.


3. Liskov substitution

This principle states that we should be able to replace an object of a given class with any object of its subclasses without breaking or changing the correctness of the program.

In our code we have WhatsAppBirthdayMessageSender and EmailBirthdayMessageSender which are subclasses of AIBirthdayMessageSender.
But AIBirthdayMessageSender.send() method defines a parameter phone that cannot be applied to all subclasses, especially to EmailBirthdayMessageSender which doesn't have phone but email instead. This breaks the Liskov substitution principle and to fix that, we can just remove the argument phone from the parent, because it's not an argument that is general to AIBirthdayMessageSender, like message in the constructor, but it's specific to the WhatsAppBirthdayMessageSender.

Here is it new code:

class AIBirthdayMessageSender(ABC):

    def __init__(self, message: str):
        self.message = message

    @abstractmethod
    def send(self):
        pass
Enter fullscreen mode Exit fullscreen mode

That way, we will no longer be allowed to call AIBirthdayMessageSender.send() with phone as it belongs to WhatsAppBirthdayMessageSender
subclass.


4. Interface segregation

This principle states that a class or a method should not be forced to do what it's not supposed to.
In other words, it's better to have several different interfaces that do different things than a single interface that tries to be accommodated to do all the things.

Another way we could have modified our code to respect the Liskov substitution principle is to do this:

class AIBirthdayMessageSender(ABC):
    ...
    @abstractmethod
    def send(self, phone=None, email=None, facebook_username=None,):
        pass
Enter fullscreen mode Exit fullscreen mode

We have a single global interface in the parent class, with all the possible arguments for the send method of all it subclasses, and we make them optional.
so for each subclass, we can just pass the needed arguments and the others will be None.

That will work. But it breaks the Interface segregation principle because we are forcing the poor
AIBirthdayMessageSender.send() method that didn't ask for anything, to handle all subclasses cases.
So the right way is still what we have done previously. Each send method of each subclass defined its specific own arguments.


5. Dependency Inversion

This last principle states that high-level classes should depend on abstractions and not on concrete classes or low-level classes.

First, let's try to add a high-level class. It's just the main class that calls other classes to handle the business logic.

We have added a User class and a business logic class called AIBirthdayManager that reuse low-level classes like ChatGptBirthdayMsgGenerator
or WhatsAppBirthdayMessageSender to handle some features around the birthday of a given User:

We have modified ChatGptBirthdayMsgGenerator to inherit it from an abstraction:

class User:
    def __init__(self, name, email, phone):
        self.name = name
        self.email = email
        self.phone = phone

class AIBirthdayManager:

    def __init__(self, user: User):
        self.user = user

    def order_a_gift(self):
        pass

    def send_happy_birthday_message(self):
        msg_generator = ChatGptBirthdayMsgGenerator(self.user.name)
        message = msg_generator.generate()
        whatsapp_sender = WhatsAppBirthdayMessageSender(message)
        whatsapp_sender.send(phone="022990149224")

if __name__ == "__main__":
    user = User("Johnathan Smith", "j_smith@gmail.com", "022990149224")
    birthday_manager = AIBirthdayManager(user=user)
    birthday_manager.send_happy_birthday_message()
Enter fullscreen mode Exit fullscreen mode

The issue with this is that, in the future if we want to change the AI provider to a different one, like BardAI for instance, we will be obliged to modify the AIBirthdayManager class which breaks Opene/close principle.
That's why AIBirthdayManager should not directly depend on ChatGptBirthdayMsgGenerator, but on an abstraction of it like this:

class AIBirthdayMsgGenerator(ABC):
    def __init__(self, receiver_name: str):
        self.receiver_name = receiver_name

    @abstractmethod
    def generate(self):
        pass

class ChatGptBirthdayMsgGenerator(AIBirthdayMsgGenerator):
    """Generate a birthday message from chat GPT."""
    def generate(self):
        print("Authenticate to ChatGPT API and request for birthday message")
        return f"Happy Birthday {self.receiver_name} from ChatGPT"

...

class AIBirthdayManager:
    def __init__(self, user, ai_class: AIBirthdayMsgGenerator):
        self.user = user
        self.ai_class = ai_class

    def send_happy_birthday_message(self):
        msg_generator = self.ai_class(self.user.name)
        ...

if __name__ == "__main__":
    user = User("Johnathan Smith", "j_smith@gmail.com", "022990149224")
    birthday_manager = AIBirthdayManager(
        user=user,
        ai_class=ChatGptBirthdayMsgGenerator
    )
    birthday_manager.send_happy_birthday_message()
Enter fullscreen mode Exit fullscreen mode

Now our high-level module AIBirthdayManager depends on the abstraction AIBirthdayMsgGenerator and to change the AI provider we just have to create another subclass of AIBirthdayMsgGenerator and pass it to ai_class argument in the constructor.
Let's implement BardAI:

class BardAIBirthdayMsgGenerator(AIBirthdayMsgGenerator):
    """Generate a birthday message from BardAI."""
    def generate(self):
        print("Authenticate to BardAI API and request for birthday message")
        return f"Happy Birthday {self.receiver_name} from BardAI"

if __name__ == "__main__":
    user = User("Johnathan Smith", "j_smith@gmail.com", "022990149224")
    birthday_manager = AIBirthdayManager(
        user=user,
        ai_class=BardAIBirthdayMsgGenerator
    )
    birthday_manager.send_happy_birthday_message()
Enter fullscreen mode Exit fullscreen mode

The new output should be:

Authenticate to BardAI API and request for birthday message
Authenticate to Whatsapp API and send message to 022990149224
message sent: ~~~Happy Birthday Johnathan Smith from BardAI~~~
Enter fullscreen mode Exit fullscreen mode

That's all.
Thanks for reading.
Don't hesitate to check the video version of this article here.

💖 💪 🙅 🚩
fayomihorace
Horace FAYOMI

Posted on March 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related