Mastering SOLID Principles in Python: A Guide to Scalable Coding
Tags: webdev, python, beginners, designpatterns
Published: 2023-07-04 18:17:43

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.

The` Insurance` 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` classes` HMOPolicy` and `PPOPolicy`, which inherit from the abstract `Insurance `class. Both `concrete classes implement the `calculate_discount` method, providing their implementation of the method defined in the `abstract` class. The `HMOPolicy `class overrides the `calculate_discount` method 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](https://tinyurl.com/446x3fnb). This copy is modified!_