Mastering SOLID Principles in Python: A Guide to Scalable Coding
Olatunde Adedeji
Posted on July 4, 2023
SOLID principles are a set of software design principles that aim to help software developers write scalable and maintainable codes. These standard principles for software design are the Single responsibility principle
, Open-closed principle
, Liskov substitution principle
, Interface segregation principle
, and Dependency inversion principle
. Before the SOLID principles adoption, software developers faced several challenges related to writing codes that are easier to maintain and understand. Tech folks that design with codes faced issues related to code becoming tangled and difficult to understand, which was sometimes referred to as spaghetti code
. This made it difficult to make changes or add new features to the codebase and could result in bugs and other unintended issues.
Another prevailing problem precedent to SOLID was code rigidity, where making changes to one part of the codebase would require changes to many other parts of the codebase. This made it difficult to make changes to the codebase without introducing bugs or breaking existing functionality. We also experienced a lack of modularity, where code was not divided into smaller, more manageable components. This made it difficult to reuse code and made the codebase more difficult to understand and maintain.
SOLID principles were introduced by Robert C. Martin, also known as Uncle Bob, in the early 2000s to address these and other challenges by providing a set of best practices for writing maintainable and scalable code. With the SOLID creed, software developers can create code that is easier to understand, easier to maintain, and more flexible in the face of changing requirements.
Next, we discuss the components of SOLID principles.
What are SOLID Design Principles
SOLID principles are software design best practices formulated to help software developers write maintainable, scalable, and flexible code.
Let's delve deeper into understanding SOLID principles with some Python code examples to drive SOLID creed into our blood streams:
The five SOLID principles are:
- Single Responsibility Principle (SRP): A class should have a single responsibility or a single job
- Open/Closed Principle (OCP): A class should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): derived classes should be substitutable for their base classes.
- Interface Segregation Principle (ISP): Clients/classes that use an interface should not be forced to depend on interfaces they do not use.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions.
Next, we will slice each principle and sprinkle some Python codes on these best practices to help us understand the SOLID principles better.
Single Responsibility Principle
The Single Responsibility Principle (SRP) is a fundamental SOLID principle that states that a class should have single responsibility or a single job. This principle helps in making the code more maintainable, testable, and reusable.
Let's buttress the point with a healthcare application.
To demonstrate the single responsibility principle using a sample Python code, let's consider a case study of a healthcare application. Suppose we have a class called Patient that has the following properties: name
, age, gender
, blood_group
, patient_id
, medical_history
, appointments
, and treatments
.
However, the Patient
class violates the SRP principle because it has too many responsibilities. It stores patient data
, medical history
, appointments
, and treatments
all in one class. A better approach would be to separate the concerns of patient data storage and appointment scheduling into two separate classes.
A code example is provided below to illustrate this:
class Patient:
def __init__(self, name, age, gender, blood_group, patient_id, medical_history, appointments, treatments):
self.name = name
self.age = age
self.gender = gender
self.blood_group = blood_group
self.patient_id = patient_id
self.medical_history = medical_history
self.appointments = appointments
self.treatments = treatments
def get_patient_data(self):
# return patient data
def get_medical_history(self):
# return medical history
def get_appointments(self):
# return appointments
def get_treatments(self):
# return treatments
def schedule_appointment(self, date):
# schedule an appointment
def add_treatment(self, treatment):
# add a new treatment
The preceding Patient
class violates the SRP principle because it has too many responsibilities. We can refactor the code and separate concerns by creating a PatientData
class and an AppointmentScheduler
class.
class PatientData:
def __init__(self, name, age, gender, blood_group, patient_id, medical_history, treatments):
self.name = name
self.age = age
self.gender = gender
self.blood_group = blood_group
self.patient_id = patient_id
self.medical_history = medical_history
self.treatments = treatments
def get_patient_data(self):
# return patient data
def get_medical_history(self):
# return medical history
def get_treatments(self):
# return treatments
class AppointmentScheduler:
def __init__(self, patient_data):
self.patient_data = patient_data
self.appointments = []
def get_appointments(self):
# return appointments
def schedule_appointment(self, date):
# schedule an appointment
In the preceding refactored code, we have separated concerns and created two classes. The PatientData
class stores patient data, medical history, and treatments, while the AppointmentScheduler
class handles appointment scheduling.
With the single responsibility principle implemented, we have made the code more maintainable, testable, and reusable.
Next up on the list is the oOpen/closed principle.
Open/Closed Principle
The Open/Closed Principle (OCP) is another component of the SOLID principle that states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, we should be able to extend the behaviour of a class without modifying its source code. This principle promotes code reusability and maintainability.
Let's continue with the healthcare application use case and demonstrate the open/closed principle using Python code. Suppose we have a class called Billing
that calculates the bill for the patient's treatment.
This is how the Billing
class can be written without adhering to the open/closed principle:
class Billing:
def __init__(self, patient, amount):
self.patient = patient
self.amount = amount
def calculate_bill(self):
# calculate bill based on treatment amount
if self.patient.insurance == 'HMO':
self.amount = self.amount * 0.8
elif self.patient.insurance == 'PPO':
self.amount = self.amount * 0.9
return self.amount
The preceding Billing
class violates the OCP principle because if we want to add a new type of insurance, we have to modify the source code of the Billing
class, which can introduce bugs and make the code difficult to maintain.
To comply with the OCP principle, we can modify the Billing
class to use a strategy pattern. We can create an abstract Insurance
class and have specific insurance classes extend it. The Billing
class can then accept an instance of the Insurance
class, which will be used to calculate the bill.
Let's create an Insurance
abstract class:
from abc import ABC, abstractmethod
class Insurance(ABC):
@abstractmethod
def calculate_discount(self, amount):
pass
The preceding code defines an abstract
class with the name Insurance
using the Python standard library's abc
module, which stands for Abstract Base Classes
. The ABC
class serves as the base class for all other abstract classes.
TheInsurance
class contains a single abstract
method named calculate_discount
. An abstract
method is a method that is declared in the abstract
class but does not have an implementation. Instead, any subclass of the Insurance
class must provide an implementation for this method.
In this case, the calculate_discount
method takes in an amount
parameter and returns a discounted amount
based on the specific insurance policy. By defining this method as abstract
in the Insurance
class, any concrete implementation of an insurance policy must provide its implementation of this method.
Now, let's implement two insurance classes (HMOPolicy
and PPOPolicy
) that extend the Insurance
class.
class HMOPolicy(Insurance):
def calculate_discount(self, amount):
return amount * 0.8
class PPOPolicy(Insurance):
def calculate_discount(self, amount):
return amount * 0.9
The preceding snippets define two concrete
classesHMOPolicy
and PPOPolicy
, which inherit from the abstract Insurance
class. Both concrete classes implement the
calculate_discountmethod, providing their implementation of the method defined in the
abstractclass. The
HMOPolicy class overrides the
calculate_discountmethod to provide a discount of 20% (0.8) on the given
amount`. This means that if a patient is covered by an HMO insurance policy, they will receive a 20% discount on their medical bills.
Likewise, the PPOPolicy
class overrides the calculate_discount
method to provide a discount of 10% (0.9) on the given amount. This means that if a patient is covered by a PPO insurance policy, they will receive a 10% discount on their medical bills.
Finally, the modified Billing class now looks like this:
`
class Billing:
def init(self, patient, amount, insurance_policy):
self.patient = patient
self.amount = amount
self.insurance_policy = insurance_policy
def calculate_bill(self):
# calculate bill based on treatment amount
return self.insurance_policy.calculate_discount(self.amount)
`
The preceding code defines a Billing
class that takes three parameters in its constructor: patient
, amount
, and insurance_policy
. The patient
parameter is a string that represents the name of the patient, the amount
parameter is a numerical value that represents the treatment cost, and the insurance_policy
parameter is an object that implements the Insurance
class (or its subclass).
The calculate_bill()
method calculates the patient's bill based on the amount of the treatment cost and the discount provided by the insurance policy. The method calls the calculate_discount()
method of the insurance_policy
object and passes the treatment cost (self.amount)
as a parameter to the method. The calculate_discount()
method of the insurance_policy
object calculates the discount
based on the type of insurance policy and returns the discounted amount. In short, the Billing
class calculates the patient's bill based on the treatment cost and the type of insurance policy they have.
With the open/closed principle implemented, the Billing
class is easier to maintain and extend. We can now add new insurance types by creating new classes that extend the Insurance
class without modifying the source code of the Billing
class.
And that's not all with SOLID principles, we have next on the list, the Liskov substitution principle.
Liskov Substitution Principle
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, if a function takes an object of a superclass as a parameter, it should also be able to accept objects of its subclasses without causing any errors or unexpected behaviours.
Let's continue with the healthcare application example and demonstrate the Liskov substitution principle using Python code. Suppose we have a class called Patient
with a method called pay_bill
that pays the bill for the patient's treatment.
Without Liskov substitution principle implemented, Patient
class could be written this way:
`
class Patient:
def init(self, name, balance):
self.name = name
self.balance = balance
def pay_bill(self, amount):
if self.balance >= amount:
self.balance -= amount
print(f"Paid {amount} for the treatment")
else:
print("Insufficient balance. Please add funds")
`
The above Patient
class violates the LSP principle because if we create a subclass that has a different implementation of the pay_bill
method, it may cause unexpected behaviours when passed into a function that expects an object of the Patient
class.
To adhere to the LSP principle, we can modify the Patient
class to have a more general behaviour for the pay_bill
method. We can create a new subclass called InsuredPatient
that extends the Patient
class and has its implementation of the pay_bill
method.
Now, let's take a look at the modified Patient
and InsuredPatient
classes:
`
class Patient:
def init(self, name, balance):
self.name = name
self.balance = balance
def pay_bill(self, amount):
self.balance -= amount
print(f"Paid {amount} for the treatment")
class InsuredPatient(Patient):
def init(self, name, balance, insurance_policy):
super().init(name, balance)
self.insurance_policy = insurance_policy
def pay_bill(self, amount):
discounted_amount = self.insurance_policy.calculate_discount(amount)
super().pay_bill(discounted_amount)
`
In the preceding modified code, we created a new subclass called InsuredPatient
that extends the Patient
class and has its implementation of the pay_bill
method. The InsuredPatient
class also accepts an instance of the Insurance
class, which will be used to calculate the discounted bill amount.
By adhering to the Liskov substitution principle, we can now pass an object of the InsuredPatient
class into a function that expects an object of the Patient
class without causing any unexpected behaviours.
Moving right along, we have the interface segregation principle to discuss.
Interface Segregation Principle
The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. This means that classes should not be forced to implement interfaces with methods that they do not need.
In the context of the healthcare application, let's say we have an interface called Treatment
that defines a method called treat_patient
that takes a Patient
object as a parameter and performs some treatment on the patient.
Here's an example of how the Treatment
interface can be written:
class Treatment(ABC):
@abstractmethod
def treat_patient(self, patient: Patient):
pass
Now let's say we have two classes called Surgery
and Medication
that implement the Treatment
interface. The Surgery
class implements the treat_patient
method by performing surgery on the patient, while the Medication
class implements the treat_patient
method by administering medication to the patient.
However, the problem with the above design is that not all patients require surgery or medication. For instance, a patient with a broken arm may only require a cast and not surgery or medication. Therefore, it would be better to create separate interfaces for each type of treatment.
With the interface segregation principle followed, this is how the modified code looks like:
class Surgery(ABC):
@abstractmethod
def perform_surgery(self, patient: Patient):
pass
class Medication(ABC):
@abstractmethod
def administer_medication(self, patient: Patient):
pass
Now we have two separate interfaces - Surgery
and Medication
. These interfaces define methods that are specific to their respective treatments. This allows the classes that implement these interfaces to only implement the methods that they need, and not be forced to implement unnecessary methods.
With the interface segregation principle adhered to, we have improved the modularity and maintainability of our code, making it easier to add new treatments in the future without impacting the existing code.
Interestingly, the SOLID story doesn't end with the interface segregation principle, we still have the dependency inversion.
Dependency Inversion Principle Principle
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules, both should depend on abstractions. In other words, the details should depend on the abstractions, not the other way around.
In the context of the healthcare application, let's say we have HospitalManager
class responsible for managing the treatments for all the patients in the hospital. This class currently has a dependency on the Surgery
and Medication
classes, which are low-level modules.
To adhere to the dependency inversion principle, we need to introduce an abstraction between the HospitalManager
class and the Surgery
and Medication
classes. One way to achieve this is by defining an interface called TreatmentStrategy
that defines the execute_treatment
method:
class TreatmentStrategy(ABC):
@abstractmethod
def execute_treatment(self, patient: Patient):
pass
Now, we can modify the Surgery
and Medication
classes to implement the TreatmentStrategy
interface:
`
class Surgery(TreatmentStrategy):
def execute_treatment(self, patient: Patient):
# perform surgery on patient
class Medication(TreatmentStrategy):
def execute_treatment(self, patient: Patient):
# administer medication to patient
`
Next, we modify the HospitalManager
class to depend on the TreatmentStrategy
interface instead of the Surgery
and Medication
classes:
`
class HospitalManager:
def init(self, treatment_strategy: TreatmentStrategy):
self.treatment_strategy = treatment_strategy
def manage_treatment(self, patient: Patient):
self.treatment_strategy.execute_treatment(patient)
`
By doing this, we have inverted the dependency - the HospitalManager
class now depends on an abstraction TreatmentStrategy
instead of a low-level module Surgery
and Medication
. This makes the code more flexible and easier to maintain, as we can easily swap out different implementations of the TreatmentStrategy
interface without impacting the HospitalManager
class.
Overall, the dependency inversion principle helps to reduce coupling and improve the modularity and maintainability of our code.
Summary
SOLID principles are well-accepted software design principles. SOLID principles were developed to address software design challenges by providing a set of best practices for writing maintainable and scalable code. By following SOLID principles, software developers can create code that is easier to understand, easier to maintain, and more flexible in the face of changing requirements.
Other design principles such as GRASP
(General Responsibility Assignment Software Patterns), DRY
(Don't Repeat Yourself), KISS (Keep It Simple, Stupid), and YAGNI
(You Aren't Gonna Need It) are worth checking out to see how these design principles fit into your project requirements.
Note:
I published the original version on Hashnode. This copy is modified!
Posted on July 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.