DEV Community

Cover image for Mastering SOLID Principles in Python: A Guide to Scalable Coding
Olatunde Adedeji
Olatunde Adedeji

Posted on

Mastering SOLID Principles in Python: A Guide to Scalable Coding

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 Insuranceclass, 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
Enter fullscreen mode Exit fullscreen mode

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 abstractmethod 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
Enter fullscreen mode Exit fullscreen mode

The preceding snippets define two concrete classesHMOPolicy and PPOPolicy, which inherit from the abstract Insuranceclass. Both concrete classes implement thecalculate_discountmethod, providing their implementation of the method defined in theabstractclass. TheHMOPolicy class overrides thecalculate_discountmethod to provide a discount of 20% (0.8) on the givenamount`. 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)
Enter fullscreen mode Exit fullscreen mode

`

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")
Enter fullscreen mode Exit fullscreen mode

`

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")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

`
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)
Enter fullscreen mode Exit fullscreen mode

`

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!

Top comments (0)