Posted on

# SOLID Principles with Python | A story

To explore how to apply SOLID principles in practice, let's create a story.

### The Beginning

Meet Mohammad, a smart person who wants to build a robust payment system.

First of all, Mohammad plans to create a class that has multiple responsibilities, such as adding items, calculating prices, creating a verification process, and accepting payments using different methods.

System initial code:

``````class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

def pay(self, payment_type, security_code):
if payment_type == "debit":
print("Processing payment type...")
print(f"Verifying code: {security_code}.")
self.status = "paid"
elif payment_type == "credit":
print("Processing payment type...")
print(f"Verifying code: {security_code}.")
self.status = "paid"
else:
raise Exception(f"#### Unknown type: {payment_type}.")
``````

To use this class in code, Mohammad created this use case:

``````# Making orders
order = Order()
order.add_item("Clean Architecture Book, Uncle Bob", 1, 100)

print(order.total_price())

# Payment using Debit card
order.pay("debit", "123456789")
``````

Output:

``````174
Processing payment type...
Verifying code: 123456789.
``````

Everything works fine and Mohammad was satisfied for now.

### SOLID Principles

Let's assume that "Uncle Clean", a consultant, is helping Mohammad implement the SOLID principles in his program.

SOLID stands for:

• S: Single responsibility principle.
• O: Open–closed principle.
• L: Liskov substitution principle.
• I: Interface segregation principle.
• D: Dependency inversion principle.

### 1- Single Responsibility Principle (SRP)

Mohammad has asked himself about the responsibilities of the `Order` class in his code and has identified that it has multiple responsibilities, including adding items, calculating the total price, and handling payment details.

#### Uncle Clean in the Scene

Uncle Clean: The SRP (stands for Single Responsibility Principle) dictates that classes should have only a single reason to change. If your class contains multiple reasons for change; then it indicates that your code is tightly-coupled and harder to maintain.

Mohammad: What that means in my case?

Uncle Clean: What if a new customer requests a new payment method, such as Bitcoin or PayPal? In that case, you would need to modify the `Order` class. Therefore, it is recommended to separate the payment responsibility from the `Order` class.

``````class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class PaymentHandler:
def pay_debit(self, order: Order, security_code):
print("Processing Debit Card payment...")
print(f"Verifying code: {security_code}.")
order.status = "paid"

def pay_credit(self, order: Order, security_code):
print("Processing Credit Card payment...")
print(f"Verifying code: {security_code}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("Clean Architecture Book, Uncle Bob", 1, 100)

print(order.total_price())

# Payment using Credit card
payment_handler = PaymentHandler()
payment_handler.pay_credit(order, "543219876")
``````

Output:

``````174
Processing Credit Card payment...
Verifying code: 543219876.
``````

Uncle Clean: Great job, Mohammad! You're doing well. There is still room for optimization, such as creating a specific method for changing the order status, but for now, this is sufficient.

[For more details about SRP, take a look on Uncle Bob's blog from here]

### 2- Open/Closed Principle (OCP)

After a few days, a new client asked Mohammad if his program supported PayPal payments. Mohammad replied that this feature could be easily added.

To achieve that, Mohammad created a new method for PayPal, but he made a mistake while calling it, he used `pay_debit` instead of using `pay_PayPal`. Mohammad encountered additional comparable issues, which led him to realize that he was frequently modifying the `PaymentHandler` class each time a new payment feature was introduced.

#### Uncle Clean in the Scene

Uncle Clean: Hi Mohammad, After reviewing your recent difficulties when attempting to integrate new payment methods, my recommendation is to take the OCP into account as you continue to develop your code.

Uncle Clean: OCP stands for Open/Closed Principle (OCP) which states that software entities (classes, functions, ...) should be open for extension but closed for modification. This means that when new requirements arise or changes need to be made, it should be possible to extend the behavior of the software entity without modifying its source code.

Now, after considering OCP into account, the new code became:

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class PaymentHandler(ABC):
@abstractmethod
def pay(self, order: Order, security_code):
pass

class DebitPaymentHandler(PaymentHandler):
def pay(self, order: Order, security_code):
print("Processing Debit Card payment...")
print(f"Verifying code: {security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def pay(self, order: Order, security_code):
print("Processing Credit Card payment...")
print(f"Verifying code: {security_code}.")
order.status = "paid"

class PayPalPaymentHandler(PaymentHandler):
def pay(self, order: Order, security_code):
print("Processing PayPal payment...")
print(f"Verifying code: {security_code}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("The Pragmatic Programmer Book, Andy Hunt", 1, 130)

print(order.total_price())

# Payment using PayPal

paypal_payment = PayPalPaymentHandler()
paypal_payment.pay(order, "543219876")
``````

Output:

``````200
Processing PayPal payment...
Verifying code: 543219876.
``````

### 3- Liskov Substitution Principle (LSP)

Mohammad considered changing the `security_code` to `email`, but this would create a new problem with other payment methods that require a `security_code`.

#### Uncle Clean in the Scene

Uncle Clean:

LSP is created by Prof. Barbara Liskov which stands for Liskov Substitution Principle, which states that objects of a superclass should be replaceable with objects of its subclasses without breaking the program's behavior.

The result of applying Liskov Substitution Principle on the codebase was:

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class PaymentHandler(ABC):
@abstractmethod
def pay(self, order: Order):
pass

class DebitPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def pay(self, order: Order):
print("Processing Debit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def pay(self, order: Order):
print("Processing Credit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class PayPalPaymentHandler(PaymentHandler):
def __init__(self, email) -> None:
self.email = email

def pay(self, order: Order):
print("Processing PayPal payment...")
print(f"Verifying email: {self.email}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("The Pragmatic Programmer Book, Andy Hunt", 1, 130)

print(order.total_price())

# Payment using PayPal

paypal_payment = PayPalPaymentHandler("hi@customer.com")
paypal_payment.pay(order)
``````

Output:

``````200
Processing PayPal payment...
Verifying email: hi@customer.com.
``````

### 4- Interface Segregation Principle (ISP)

After a while, Mohammad contacted a cyber security expert to review the whole system and to submit a report that helps him to fix the vulnerabilities in the system for more protection.

One of the main points that this cyber security expert mentioned in his report: Adding 2FA ( Two-factor Authentication) to the system.

Based on that, Mohammad started to work on this feature by adding authentication method using SMS `auth_2fa_sms` in the `PaymentHandler`. And he implemented it to the other classes that are inheriting from the base class.

The code became as the following:

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class PaymentHandler(ABC):
@abstractmethod
def auth_2fa_sms(self, code):
pass

@abstractmethod
def pay(self, order: Order):
pass

class DebitPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code
self.verified = False

def auth_2fa_sms(self, code):
print(f"Verifying 2FA using SMS code: {code}")
self.verified = True

def pay(self, order: Order):
if not self.verified:
raise Exception("Not authenticated")
print("Processing Debit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def auth_2fa_sms(self, code):
# It is also a violation for Liskov Substitution principle
raise Exception(
"Credit card payment doesn't support SMS code authentication.")

def pay(self, order: Order):
print("Processing Credit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class PayPalPaymentHandler(PaymentHandler):
def __init__(self, email) -> None:
self.email = email

def auth_2fa_sms(self, code):
print(f"Verifying 2FA using SMS code: {code}")
self.verified = True

def pay(self, order: Order):
if not self.verified:
raise Exception("Not authenticated")
print("Processing PayPal payment...")
print(f"Verifying email: {self.email}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("Raspberry Pi Camera v2", 2, 40)

print(order.total_price())

# Payment using Debit Card

debit_payment = DebitPaymentHandler("67891")
debit_payment.auth_2fa_sms("54321")
debit_payment.pay(order)
``````

As in the above code, when the user pay for his stuff using debit card, it should be authenticated before payment process, and the expected result will be as the following:

``````156
Verifying 2FA using SMS code: 54321
Processing Debit Card payment...
Verifying code: 67891.
``````

#### Uncle Clean in the Scene

Uncle Clean: Hello Abo Ehmaid (a nickname for Mohammad), I heard that you're securing your payment software, a good news!
But what will happen if the user start using credit card?

Mohammad: Hi uncle, I know that credit card doesn't support 2FA using SMS verification, so I added an exception for this error. But I think it should be implemented in a better way. What do you advise me to do?

Uncle Clean: I suggest on you to use interface segregation.

Uncle Clean: Interface Segregation Principle (ISP) means: Clients should not be forced to depend on interfaces they do not use (SMS 2FA with credit card in your case).

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class PaymentHandler(ABC):
@abstractmethod
def pay(self, order: Order):
pass

class SMSPaymentAuthHandler(PaymentHandler):
@abstractmethod
def auth_2fa_sms(self, code):
pass

class DebitPaymentHandler(SMSPaymentAuthHandler):
def __init__(self, security_code):
self.security_code = security_code
self.verified = False

def auth_2fa_sms(self, code):
print(f"Verifying 2FA using SMS code: {code}")
self.verified = True

def pay(self, order: Order):
if not self.verified:
raise Exception("Not authenticated")
print("Processing Debit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def pay(self, order: Order):
print("Processing Credit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class PayPalPaymentHandler(SMSPaymentAuthHandler):
def __init__(self, email) -> None:
self.email = email

def auth_2fa_sms(self, code):
print(f"Verifying 2FA using SMS code: {code}")
self.verified = True

def pay(self, order: Order):
if not self.verified:
raise Exception("Not authenticated")
print("Processing PayPal payment...")
print(f"Verifying email: {self.email}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("Raspberry Pi Camera v2", 2, 40)

print(order.total_price())

# Payment using Debit Card

debit_payment = DebitPaymentHandler("67891")
debit_payment.auth_2fa_sms("54321")
debit_payment.pay(order)

print("#"*20)

# Payment using Credit Card

credit_payment = CreditPaymentHandler("97531")
credit_payment.pay(order)
``````

Output:

``````156
Verifying 2FA using SMS code: 54321
Processing Debit Card payment...
Verifying code: 67891.
####################
Processing Credit Card payment...
Verifying code: 97531.
``````

### Composition over Inheritance

Note: Composition over Inheritance is not one of the SOLID principles, but it is worth mentioning in the story context because it is more efficient in this case.

#### Uncle Clean in the Scene

Uncle Clean: Hi Mohammad, you did a good job by separating the interfaces.

Uncle Clean: Are you familiar with various types of two-factor authentication (2FA) methods?

Mohammad: Yes, I am. I know different types, such as SMS-based, authenticator apps, Email-based, and more.

Uncle Clean: That's great to hear! Now, let's say you encounter a situation where you need to incorporate multiple 2FA methods for the same payment method, like adding an authenticator app alongside SMS-based authentication for PayPal. How would you approach this? Additionally, do you believe that solely inheriting from the 2FA classes would be sufficient for your payment methods? I'm referring to the need for additional features to be added to your payment classes, beyond inheriting from the 2FA classes.

Mohammad: Well, there are situations where I could create additional classes to inherit the necessary features for payment methods. However, I'm concerned about the long-term practicality if I keep adding more and more features. Uncle, I would like to hear your thoughts on this matter.

Uncle Clean: In certain scenarios, employing inheritance may not be the most practical or efficient approach to transferring features from one class to another. In such cases, it is advisable to consider the principle of Composition over Inheritance (CoI) as a better alternative.

Mohammad: In simple words, what do we mean by that?

Uncle Clean: Composition over inheritance is a principle in OOP that suggests favoring composition, or building objects by combining simpler components, over inheritance, where objects inherit properties and behaviors from parent classes. With composition, we can combine smaller, modular components for greater flexibility and code reusability. It reduces tight coupling, avoids fragility, and improves code readability and maintainability. Embracing composition will result in more efficient and adaptable code.

Mohammad: Interesting! I will do my best to apply that. Thanks uncle.

Uncle Clean: Habibi teslam. (a greeting word in Arabic)

After reviewing different examples, Mohammad changed his codebase to the following one: (Added SMSAuthorizer and created an object in debit card class)

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class SMSAuthorizer:

authorized = False

def verify_code(self, code):
print(f"Verifying code {code}")
self.authorized = True

def is_authorized(self):
return self.authorized

class PaymentHandler(ABC):
@abstractmethod
def pay(self, order: Order):
pass

class DebitPaymentHandler(PaymentHandler):
def __init__(self, security_code, authorizer: SMSAuthorizer):
self.authorizer = authorizer
self.security_code = security_code

def pay(self, order: Order):
if not self.authorizer.is_authorized():
raise Exception("Not authenticated")
print("Processing Debit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def pay(self, order: Order):
print("Processing Credit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class PayPalPaymentHandler(PaymentHandler):
def __init__(self, email, authorizer: SMSAuthorizer) -> None:
self.authorizer = authorizer
self.email = email

def pay(self, order: Order):
if not self.authorizer.is_authorized():
raise Exception("Not authenticated")
print("Processing PayPal payment...")
print(f"Verifying email: {self.email}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("Raspberry Pi Camera v2", 2, 40)

print(order.total_price())

# SMS Authorizer
sms_authorizer = SMSAuthorizer()

# Payment using Debit Card

debit_payment = DebitPaymentHandler("67891", sms_authorizer)
sms_authorizer.verify_code("24682")
debit_payment.pay(order)
``````

Output:

``````156
Verifying code 24682
Processing Debit Card payment...
Verifying code: 67891.
``````

### 5- Dependency Inversion Principle (DIP)

At this point, Mohammad was smart enough to read about DIP principle 🤣, where he called uncle Clean and introduced DIP for him.

#### Uncle Clean in the Scene

Mohammad: Hi uncle, I want to know that I worked on DIP during last week.

Uncle Clean: What do you mean by DIP?

Mohammad: I can't imagine that, I am explaining a tech topic for uncle Clean! 😊

Anyway, DIP stands for the Dependency Inversion Principle. The principle is about removing dependencies from high-level code to low-level code by creating interfaces, such as `Authorizer` in my case. As a result, both high-level and low-level code depend on these interfaces.

Uncle Clean: Great job Mohammad, you're amazing. Based on that, what you've changed in your code?

Mohammad: I created `Authorizer`, where I pass it to the suitable payment methods, and this gives me the ability to pass different authorization types if they are subclasses from the `Authorizer` interface. Here is the code below:

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class Authorizer(ABC):
@abstractmethod
def is_authorized(self):
pass

class SMSAuthorizer(Authorizer):

authorized = False

def verify_code(self, code):
print(f"Verifying code {code}")
self.authorized = True

def is_authorized(self):
return self.authorized

class PaymentHandler(ABC):
@abstractmethod
def pay(self, order: Order):
pass

class DebitPaymentHandler(PaymentHandler):
def __init__(self, security_code, authorizer: Authorizer):
self.authorizer = authorizer
self.security_code = security_code

def pay(self, order: Order):
if not self.authorizer.is_authorized():
raise Exception("Not authenticated")
print("Processing Debit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def pay(self, order: Order):
print("Processing Credit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class PayPalPaymentHandler(PaymentHandler):
def __init__(self, email, authorizer: Authorizer) -> None:
self.authorizer = authorizer
self.email = email

def pay(self, order: Order):
if not self.authorizer.is_authorized():
raise Exception("Not authenticated")
print("Processing PayPal payment...")
print(f"Verifying email: {self.email}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("Raspberry Pi Camera v2", 2, 40)

print(order.total_price())

# SMS Authorizer
sms_authorizer = SMSAuthorizer()

# Payment using Debit Card

debit_payment = DebitPaymentHandler("67891", sms_authorizer)
sms_authorizer.verify_code("24682")
debit_payment.pay(order)
``````

Uncle Clean: Wow, that is a great progress. What if we want to add a reCAPTCHA authentication method? Can the Dependency Inversion Principle be helpful here?

Mohammad: Sure, I added that to my code, where I created a concrete class `reCAPTCHA_Authorizer` from `Authorizer` abstract class, and I passed it to PayPal payment class. So now I can use different authorization and authentication methods to the same payment method class. Look at this code below:

``````from abc import ABC, abstractmethod

class Order:
items = []
quantities = []
prices = []
status = "open"

self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)

def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]

class Authorizer(ABC):
@abstractmethod
def is_authorized(self):
pass

authorized = False

print("User passed `I'm not a robot` test")
self.authorized = True

def is_authorized(self):
return self.authorized

class SMSAuthorizer(Authorizer):

authorized = False

def verify_code(self, code):
print(f"Verifying code {code}")
self.authorized = True

def is_authorized(self):
return self.authorized

class PaymentHandler(ABC):
@abstractmethod
def pay(self, order: Order):
pass

class DebitPaymentHandler(PaymentHandler):
def __init__(self, security_code, authorizer: Authorizer):
self.authorizer = authorizer
self.security_code = security_code

def pay(self, order: Order):
if not self.authorizer.is_authorized():
raise Exception("Not authenticated")
print("Processing Debit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class CreditPaymentHandler(PaymentHandler):
def __init__(self, security_code):
self.security_code = security_code

def pay(self, order: Order):
print("Processing Credit Card payment...")
print(f"Verifying code: {self.security_code}.")
order.status = "paid"

class PayPalPaymentHandler(PaymentHandler):
def __init__(self, email, authorizer: Authorizer) -> None:
self.authorizer = authorizer
self.email = email

def pay(self, order: Order):
if not self.authorizer.is_authorized():
raise Exception("Not authenticated")
print("Processing PayPal payment...")
print(f"Verifying email: {self.email}.")
order.status = "paid"

# Making orders
order = Order()
order.add_item("Domain Driven Design Book, by Eric Evans", 2, 111)

print(order.total_price())

# Payment using PayPal

paypal_payment.pay(order)
``````

Output:

``````267
User passed `I'm not a robot` test
Processing PayPal payment...
Verifying email: hi@customer.com.
``````

### Final Thoughts

Firstly, I want to thank my characters in the story, Mohammad and uncle Clean.

Secondly, SOLID principles aim to make software more flexible, maintainable, scalable, and testable by reducing code dependencies and making designs easier to understand, maintain, and extend.

However, SOLID principles are not always applicable in every situation, and it is important to use them correctly and embrace the soul behind the SOLID rules. 😊

By following these guidelines, developers can create cleaner code that is easier to modify and understand, leading to better collaboration among team members and more robust, flexible, and reusable software.

Finally, happy coding.