SOLID Principles
In the ever-changing world of software development, keeping your code clean and easy to manage is super important. This post talks about the five SOLID principles that are key for creating strong and scalable software.
Single Responsibility
A class should have a single responsibility.
If we are using the same class for Managers and those who are reporting to them because they are both employees of the company, it will introduce unwanted impact when changes are introduced for either of them. It is better to keep them separate as they are acting in separate roles in the organization.
The end goal of this principle is to avoid any changes in one role that should not affect the other role. Otherwise, if recent changes introduce any bugs, it will affect the unrelated role.
Let us take the example of creating a business logic for keeping the orders of a shop.
class Order:
def __init__(self):
self.items = []
self.quantities = []
self.prices = []
self.status = "open"
def add_item(self, name, quantity, price):
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]
return total
def pay(self, payment_type, security_code):
if payment_type == "debit":
print("Processing debit payment type")
print(f"Verifying security code: {security_code}")
self.status = "paid"
elif payment_type == "credit":
print("Processing credit payment type")
print(f"Verifying security code: {security_code}")
self.status = "paid"
else:
raise Exception(f"Unknown payment type: {payment_type}")
If we notice, the Order class is overburdened by multiple responsibilities such as: adding items to the cart, calculating the total items, and figuring out the type of payment method. However, according to the Single Responsibility Principle, we can assign a single task responsibility to the order, which is taking care of the items added and their corresponding price calculated for it.
For the payment processing we can have a separate logic which will handle the logic of different payment methods like below.
class PaymentProcessor:
def debit_payment(self, order, security_code):
print("processing debit payment")
print("verifying security code")
order.status = "paid"
def credit_payment(self, order, security_code):
print("processing credit payment")
print("verifying security code")
order.status = "paid"
Concluded Design:
class Order:
def __init__(self):
self.items = []
self.quantities = []
self.prices = []
self.status = "open"
def add_item(self, name, quantity, price):
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]
return total
class PaymentProcessor:
def debit_payment(self, order, security_code):
print("processing debit payment")
print("verifying security code")
order.status = "paid"
def credit_payment(self, order, security_code):
print("processing credit payment")
print("verifying security code")
order.status = "paid"
if __name__ == "__main__":
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
pay_obj = PaymentProcessor()
pay_obj.debit_payment(order, "122334")
Open-Closed
A class should be Open for extension and closed for modification.
This principle may conflict with Single Responsibility, but based on situations, we can extend the responsibility. We must understand that adding an action to a role should not change the role. This principle talks about it.
Likewise, in the example above, a project manager can act as a scrum master. However, it's different from taking responsibility for a project director. Here, the manager extends the responsibility along with his current role. Based on extensibility, we decide on the extension or create a new role.
The end goal of this principle is to avoid triggering bugs in the existing implementation of that class if there are any changes in a class signature. Extending the action of that class might ensure backward compatibility.
In the above shop order example, we could see that if any new payment method is introduced, it will trigger a modification of the PaymentProcessor class, violating our Open-Closed principle.
To overcome the above issue, we will allow the PaymentProcessor to be extended but not modified.
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, order, security_code):
pass
class DebitPaymentProcessor(PaymentProcessor):
def pay(self, order, security_code):
print("processing debit payment")
print("verifying security code")
order.status = "paid"
class CreditPaymentProcessor(PaymentProcessor):
def pay(self, order, security_code):
print("processing credit payment")
print("verifying security code")
order.status = "paid"
Now, if we want to add a new payment method to the shop, all we need to do is extend it from the PaymentProcessor and add its own specifications.
Concluded Design:
from abc import ABC, abstractmethod
class Order:
def __init__(self):
self.items = []
self.quantities = []
self.prices = []
self.status = "open"
def add_item(self, name, quantity, price):
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]
return total
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, order, security_code):
pass
class DebitPaymentProcessor(PaymentProcessor):
def pay(self, order, security_code):
print("processing debit payment")
print("verifying security code")
order.status = "paid"
class CreditPaymentProcessor(PaymentProcessor):
def pay(self, order, security_code):
print("processing credit payment")
print("verifying security code")
order.status = "paid"
if __name__ == "__main__":
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
pay_obj = DebitPaymentProcessor()
pay_obj.pay(order, "122334")
Liskov Substitution
This principle says, as a child, you might be able to serve the purpose of your parent as a substitute.
If a class Benz is a subclass of type Car, wherever there is a need for a Car, the Benz should be able to fulfil the need.
An Associate Tech Lead can function as a Tech Lead when a substitution is needed from the Tech Lead.
The goal of this principle is to maintain consistency in the inheritance of properties without compromising.
Now, in the same example of the shop we are trying to add a new payment method like below:
class PaypalPaymentProcessor(PaymentProcessor):
def pay(self, order, email):
print("processing paypal payment")
print("verifying security code")
order.status = "paid"
If we compare this to the other payment types, it takes 'email' instead of the 'security_code' for validation. However, this is not aligned with our Liskov Substitution
principle because it demands consistency among classes at the same level that are inherited from the same parent. This consistency will allow us to perform object substitutions without any issue.
Let us tweak this code to adapt to this principle.
from abc import ABC, abstractmethod
class Order:
def __init__(self):
self.items = []
self.quantities = []
self.prices = []
self.status = "open"
def add_item(self, name, quantity, price):
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]
return total
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, order):
pass
class DebitPaymentProcessor(PaymentProcessor):
def __init__(self, security_code):
self.security_code = security_code
def pay(self, order):
print("processing debit payment")
print(f"verifying {self.security_code}")
order.status = "paid"
class CreditPaymentProcessor(PaymentProcessor):
def __init__(self, security_code):
self.security_code = security_code
def pay(self, order):
print("processing credit payment")
print(f"verifying {self.security_code}")
order.status = "paid"
class PaypalPaymentProcessor(PaymentProcessor):
def __init__(self, email):
self.email = email
def pay(self, order):
print("processing paypal payment")
print(f"verifying {self.email}")
order.status = "paid"
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
pay_obj = PaypalPaymentProcessor("[email protected]")
pay_obj.pay(order)
Interface Segregation
This principle says clients should not be forced to depend on the methods that they do not use.
If a class 3GCustomer
is inheriting from a TelecomCompany where this company offers a recharge pack that includes 4G benefits, which cannot be used by the 3GCustomer
, but they depend on the package for talk time and end up paying extra for this unwanted feature.
The end goal of this principle is to split the set of actions into smaller sets, which will execute actions specific to themselves.
In the same example, let's say the transactions are getting complex. Some of the payment methods require OTP confirmation via SMS, and others do not require this kind of validation.
Now we can segregate the payment processors based on their nature instead of including both types of payment processors on a single interface.
from abc import ABC, abstractmethod
class Order:
def __init__(self):
self.items = []
self.quantities = []
self.prices = []
self.status = "open"
def add_item(self, name, quantity, price):
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]
return total
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, order):
pass
class OTPPaymentProcessor(PaymentProcessor):
@abstractmethod
def sms_auth(self, otp):
pass
class DebitPaymentProcessor(OTPPaymentProcessor):
def __init__(self, security_code):
self.security_code = security_code
self.otp_verified = False
def sms_auth(self, otp):
self.otp_verified = True
def pay(self, order):
if not self.otp_verified:
raise Exception("Invalid OTP")
print(f"verifying {self.security_code}")
print("processing debit payment")
order.status = "paid"
class CreditPaymentProcessor(PaymentProcessor):
def __init__(self, security_code):
self.security_code = security_code
def pay(self, order):
print(f"verifying {self.security_code}")
print("processing credit payment")
order.status = "paid"
class PaypalPaymentProcessor(OTPPaymentProcessor):
def __init__(self, email):
self.email = email
self.otp_verified = False
def sms_auth(self, otp):
self.otp_verified = True
def pay(self, order):
if not self.otp_verified:
raise Exception("Invalid OTP")
print(f"verifying {self.email}")
print("processing paypal payment")
order.status = "paid"
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
print(order.total_price())
pay_obj = PaypalPaymentProcessor("[email protected]")
pay_obj.sms_auth("1234")
pay_obj.pay(order)
Dependency inversion
High-Level module should not depend on the Low-Level module. Both should depend on abstraction. Abstraction should not depend on the details. Detail should depend on the abstraction.
If a class Store
(High-Level) is explicitly dependent on a CreditCard
(Low-Level) payment processor, it will be difficult to adapt to new payment modes in the future. This can trigger unnecessary code changes in the Store
and may lead to unwanted bugs.
To avoid this, we can create an Interface (abstraction) PaymentProcessor
and allow every new payment mode to implement this interface like CreditCardPaymentProcessor
or DebitCardPaymentProcessor
, which are dependent on this abstraction.
Use the same abstraction in the Store so that any newly introduced payment will not trigger any changes to it. We need to make sure that the abstraction does not explicitly depend on any payment mode, but that the payment mode details (how it works) depend on or satisfy the abstraction requirements.
The end goal of this principle is to reduce the dependency between the High- Level & Low-Level modules by introducing the interface.
from abc import ABC, abstractmethod
class Order:
def __init__(self):
self.items = []
self.quantities = []
self.prices = []
self.status = "open"
def add_item(self, name, quantity, price):
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]
return total
"""
Dependency inversion means not depending on the any of the
subclass but depending on the abstraction which is common
for all the subclasses.
"""
class Authorizer(ABC):
@abstractmethod
def is_authorized(self) -> bool:
pass
class AuthorizerSMS(Authorizer):
def __init__(self):
self.authorized = False
def verify_code(self, code):
print(f"Verifying SMS code {code}")
self.authorized = True
def is_authorized(self) -> bool:
return self.authorized
class AuthorizerGoogle(Authorizer):
def __init__(self):
self.authorized = False
def verify_code(self, code):
print(f"Verifying Google auth code {code}")
self.authorized = True
def is_authorized(self) -> bool:
return self.authorized
class AuthorizerRobot(Authorizer):
def __init__(self):
self.authorized = False
def not_a_robot(self):
self.authorized = True
def is_authorized(self):
return self.authorized
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, order):
pass
"""
In the below classes initializers depend on the authorizer abstract class, rather than the individual authorizers.
"""
class DebitPaymentProcessor(PaymentProcessor):
def __init__(self, security_code, authorizer: Authorizer):
self.security_code = security_code
self.authorizer = authorizer
def pay(self, order):
if not self.authorizer.is_authorized():
raise Exception("Not authorized")
print("Processing debit payment type")
print(f"Verifying security code: {self.security_code}")
order.status = "paid"
class CreditPaymentProcessor(PaymentProcessor):
def __init__(self, security_code):
self.security_code = security_code
def pay(self, order):
print("Processing credit payment type")
print(f"Verifying security code: {self.security_code}")
order.status = "paid"
class PaypalPaymentProcessor(PaymentProcessor):
def __init__(self, email_address, authorizer: Authorizer):
self.email_address = email_address
self.authorizer = authorizer
def pay(self, order):
if not self.authorizer.is_authorized():
raise Exception("Not authorized")
print("Processing paypal payment type")
print(f"Using email address: {self.email_address}")
order.status = "paid"
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
print(order.total_price())
authorizer = AuthorizerRobot()
authorizer.not_a_robot()
processor = PaypalPaymentProcessor("[email protected]", authorizer)
processor.pay(order)