SOLID principles in Python

Taimur Ibrahim
7 min readJul 8, 2024

--

SOLID principles

What is SOLID?

Object oriented programming is a very useful tool for any programmer’s toolbox. However, there are some common pitfalls that most people fall into when using it.

The SOLID principles are a set of guidelines that help you avoid these pitfalls and write clean, maintainable code.

SOLID is an acronym for the following:

- Single Responsibility Principle

- Open/Closed Principle

- Liskov Substitution Principle

- Interface Segregation Principle

- Dependency Inversion Principle

1. Single Responsibility Principle (SRP)

Coined by Robert C. Martin (a.k.a Uncle Bob) in his article “The Principles of OOD”, the single responsibility principle states that

A class should have only one reason to change.

i.e. a class should have only one responsibility. If a class does more than one thing, it should be split into multiple classes.

We can look at a simple example to illustrate this. Let’s say we have a class that reads and writes objects from/to either Google Drive or Dropbox.

class StorageClient:
_instance = None
_google_client = None
_dropbox_client = None

def __init__(self, google_credentials, dropbox_credentials) -> None:
self._google_client = "Google client"
self._dropbox_client = "Dropbox client"

@classmethod
def get_or_create_instance(cls, google_credentials, dropbox_credentials) -> "StorageClient":
if not cls._instance:
cls._instance = StorageClient(google_credentials, dropbox_credentials)

return cls._instance

def read_from_google(self, key):
...

def upload_to_google(self, key, value):
...

def read_from_dropbox(self, key):
...

def upload_to_dropbox(self, key, value):
...

The problem with this class is that it has two responsibilities. It needs to implement separate logic for reading and writing objects from/to both google drive and Dropbox. In order to satisfy the SRP we can split this class into two classes: GoogleStorageClient and DropboxStorageClient.

class GoogleStorageClient:
_instance = None
_google_client = None

def __init__(self, google_credentials) -> None:
self._google_client = "Google client"

@classmethod
def get_or_create_instance(cls, google_credentials) -> "GoogleStorageClient":
if not cls._instance:
cls._instance = GoogleStorageClient(google_credentials)

return cls._instance

def read(self, key):
...

def upload(self, key, value):
...


class DropboxStorageClient:
_instance = None
_dropbox_client = None

def __init__(self, dropbox_credentials) -> None:
self._dropbox_client = "Dropbox client"

@classmethod
def get_or_create_instance(cls, dropbox_credentials) -> "DropboxStorageClient":
if not cls._instance:
cls._instance = DropboxStorageClient(dropbox_credentials)

return cls._instance

def read(self, key):
...

def upload(self, key, value):
...

Even if it is a bit more verbose, this approach allows us to develop the two clients separately and makes the code more maintainable i.e. someone working on the google client does not need to know or worry about the workings of the Dropbox client and vice versa.

2. Open/Closed Principle (OCP)

Bertrand Meyer is generally credited for having originated the term open–closed principle in his 1988 book Object-Oriented Software Construction. However, during the 1990s, the open–closed principle was redefined to it’s current state in Uncle Bob’s 1996 article “The Open-Closed Principle”.

The open/closed principle states that:

A class should be open for extension but closed for modification.

This means that you should be able to add new functionality to a class without having to change the existing code.

For example, the following class violates the open/closed principle:

class Vehicle:
def __init__(self, vehicle_type, **kwargs) -> None:
self.vehicle = vehicle_type
if self.vehicle_type == "car":
self.tires = kwargs["tires"]
self.mode = kwargs["mode"]
elif self.vehicle_type == "boat":
self.motors = kwargs["motors"]
self.mode = kwargs["mode"]

def get_specifications(self) -> str:
if self.vehicle_type == "car":
return f"This {self.vehicle_type} has {self.tires} tires and can drive on {self.mode}."
elif self.vehicle_type == "boat":
return f"This {self.vehicle_type} has {self.motors} motors and can float on {self.mode}."

The problem with this class is that if we want to add a new vehicle, say an aeroplane, we would have to modify the existing class.

Modifying existing code is risky and can introduce bugs as well as break unit tests.

Instead, we can define an abstract base class and use inheritance to make our class obey the open/closed principle.

from abc import ABC, abstractmethod


class Vehicle(ABC):
def __init__(self, mode) -> None:
self.mode = mode

@abstractmethod
def get_specifications(self) -> str:
...

class Car(Vehicle):
def __init__(self, tires) -> None:
super().__init__("lane")
self.tires = tires

def get_specifications(self) -> str:
return f"This car has {self.tires} tires and can drive on {self.mode}."

class Boat(Vehicle):
def __init__(self, motors) -> None:
super().__init__("water")
self.motors = motors

def get_specifications(self) -> str:
return f"This boat has {self.motors} motors and can float on {self.mode}."

class Plane(Vehicle):
def __init__(self, engines) -> None:
super().__init__("air")
self.engines = engines

def get_specifications(self) -> str:
return f"This plane has {self.engines} engines and can fly through the {self.mode}."

Now, if we want to add a new vehicle, we can simply create a new class that inherits from the Vehicle class and implements the get_specifications method.

3. Liskov Substitution Principle (LSP)

The Liskov substitution principle was introduced by Barbara Liskov at an OOPSLA conference in 1987. It states that:

Objects of a parent class should be replaceable with objects of its child classes without affecting the correctness of the program.

In other words, if `S` is a sub-class of `T`, then objects of type `T` should be replaceable with objects of type `S` without altering the functionality of the program.

For example, consider the following classes:

class Person:
def __init__(self, name, age) -> None:
self.name = name
self.age = age

def get_name(self) -> str:
return self.name

def vote(self, give_vote) -> int:
if give_vote:
return 1
return 0


class Child(Person):
def __init__(self, name, age) -> None:
super().__init__(name, age)

def vote(self) -> None:
raise NotImplementedError("Children cannot vote.")

The problem with this code is that the Child class violates the Liskov substitution principle. If we try to replace an object of type Person with an object of type Child, the program will not behave as expected when, for example, it wants to use the vote method.

To fix this, we can turn Person into an abstract base class and make two classes Child and Adult that inherit from it.

from abc import ABC, abstractmethod


class Person(ABC):
def __init__(self, name, age) -> None:
self.name = name
self.age = age

def get_name(self) -> str:
return self.name


class Child(Person):
def __init__(self, name, age) -> None:
super().__init__(name, age)

def go_to_school(self) -> None:
print(f"{self.name} is going to school.")


class Adult(Person):
def __init__(self, name, age) -> None:
super().__init__(name, age)

def vote(self) -> int:
return 1

Now, we can replace objects of type Person with objects of type Child or Adult without affecting the correctness of the program.

4. Interface Segregation Principle (ISP)

The interface segregation principle (ISP) was also coined by Uncle Bob. The principle’s states that:

A client should never be forced to implement an interface that it doesn’t use or clients shouldn’t be forced to depend on methods they do not use.

This means that we should avoid large interfaces that have methods that are not used by all clients that implement the interface.

For example, consider the following interface:

from abc import ABC, abstractmethod


class Printer(ABC):
def scan(self) -> None: ...

def fax(self) -> None: ...

def print(self) -> None: ...


class SimplePrinter(Printer):
def scan(self) -> None:
raise NotImplementedError("This printer cannot scan.")

def fax(self) -> None:
raise NotImplementedError("This printer cannot fax.")

def print(self) -> None:
print("Printing...")


class AdvancedPrinter(Printer):
def scan(self) -> None:
print("Scanning...")

def fax(self) -> None:
print("Faxing...")

def print(self) -> None:
print("Printing...")

In this case, the SimplePrinter class does not need the scan and fax methods, but it is forced to implement them because it implements the Printer interface. This violates the interface segregation principle.

Instead, we can split the Printer interface into three separate interfaces: Scanner, Fax, and Printer.

from abc import ABC, abstractmethod


class Scanner(ABC):
@abstractmethod
def scan(self) -> None:
...


class Fax(ABC):
@abstractmethod
def fax(self) -> None:
...


class Printer(ABC):
@abstractmethod
def print(self) -> None:
...


class SimplePrinter(Printer):
def print(self) -> None:
print("Printing...")


class AdvancedPrinter(Scanner, Fax, Printer):
def scan(self) -> None:
print("Scanning...")

def fax(self) -> None:
print("Faxing...")

def print(self) -> None:
print("Printing...")

Now the SimplePrinter class only needs to implement the Printer interface, and the AdvancedPrinter class can implement all three interfaces.

This approach makes the code easier to understand and removes the need for unnecessary methods in the SimplePrinter class.

5. Dependency Inversion Principle

Also coined by Uncle Bob, the dependency inversion principle states that:

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

This principle is about decoupling high-level modules from low-level modules by introducing an abstraction layer between them. This helps in creating loosely coupled and flexible systems.

The following is an example of a violation of the dependency inversion principle. A high-level module PaymentService directly depends on a low-level module PaypalProcessor:

class PaypalProcessor:
def process_payment(self, amount):
print(f"Processing payment of ${amount} via PayPal")


class PaymentService:
def __init__(self) -> None:
self.payment_processor = PaypalProcessor()

def perform_payment(self, amount):
self.payment_processor.process_payment(amount)


payment_service = PaymentService()
payment_service.perform_payment(100)

If we want to switch to a different payment gateway, we would have to modify the PaymentService class, which violates the Open-closed principal.

Instead, suppose we have a high-level module PaymentService that processes payments and an abstract interface PaymentProcessor that allows us to add processors that interact with different payment gateways (e.g., PayPal, Stripe)

from abc import ABC, abstractmethod


class PaymentProcessor(ABC):
@abstractmethod
def process_payment(self, amount):
pass


class PayPalPaymentProcessor(PaymentProcessor):
def process_payment(self, amount):
print(f"Processing payment of ${amount} via PayPal")


class StripePaymentProcessor(PaymentProcessor):
def process_payment(self, amount):
print(f"Processing payment of ${amount} via Stripe")


class PaymentService:
def __init__(self, payment_processor):
self.payment_processor = payment_processor

def perform_payment(self, amount):
self.payment_processor.process_payment(amount)


paypal_processor = PayPalPaymentProcessor()
payment_service = PaymentService(paypal_processor)
payment_service.perform_payment(100)

This way, the PaymentService class does not depend on a specific payment processor implementation. Instead, it depends on the PaymentProcessor interface, which allows us to switch between different payment processors without modifying the PaymentService class.

Conclusion

The SOLID principles are a set of guidelines that help you write clean, maintainable, and flexible code. By following these principles, you can create code that is easier to understand, test, and maintain. While it may take some time to get used to these principles, they will definitely help you become a better programmer and create better software. Do keep in mind that these are intended to be guidelines and not unchangeable rules, so use them wisely and adapt them to your specific needs.

--

--

Taimur Ibrahim

I'm a computer engineer who is passionate about building scalable and efficient applications.