SOLID Principles tutorial for python lovers
Horace FAYOMI
Posted on March 5, 2023
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")
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~~~
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")
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
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")
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~~~
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
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
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()
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()
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()
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~~~
That's all.
Thanks for reading.
Don't hesitate to check the video version of this article here.
Posted on March 5, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.