Pavel Loginov
Posted on November 13, 2023
Let's examine the SOLID principles through clear examples of Python code and simplify complex definitions into human language.
Note: The term “client” appears in the text. Client means a programming entity that uses another programming entity (e.g. one class uses another class inside, so the first class is a client).
SOLID is an acronym for a set of design principles created for developing software using object-oriented languages.
The SOLID principles are designed to encourage the creation of code that is simpler, more dependable, and easier to enhance. Each letter in SOLID stands for one design principle.
When implemented correctly, this makes your code more extensible, logical, maintainable, and easier to read.
To understand SOLID principles, you must have a good understanding of how interfaces are used.
Let's look at each principle one by one:
1. Single Responsibility Principle
The Single Responsibility Principle requires that each class should have a singular, clearly defined purpose.
The same applies to other programming entities. That is, it is necessary to decompose software entities so that each entity is responsible for the one job assigned to it. When a class assumes multiple responsibilities, it falls into an anti-pattern known as the God Object.
When a class takes on multiple responsibilities:
- it becomes interdependent (modifying the behavior of one class operation leads to a change in another);
- code readability declines;
- testing becomes complex;
- collaborative code development becomes challenging.
# Listing [1.1]
# An example of a class with many responsibilities.
class User:
def __init__(self, name):
self.name = name
def get_name(self):
return self.name
def save(self):
...
def send(self):
...
def log(self):
...
We have a User
class that handles multiple responsibilities - managing user properties, database operations, data transmission, and logging. If you modify the functionality of one of these tasks within the application, it may necessitate adjustments in others to accommodate the new changes. It's like a domino effect, tip one bone and it will drop everything after it.
In this case, we simply decompose the class, create separate classes that will take on one responsibility.
# Listing [1.2]
# An example of the decomposition of the `User` class.
class User:
def __init__(self, name):
self.name = name
def get_name(self):
pass
class Storage:
def save(self, user: User):
...
class HttpConnection:
def send(self, user: User):
...
class Logger:
def log(self, user: User):
...
Now our code is better structured. Individual entities are now smaller in size, making them easier to read and easier to work with. Now it is possible to give tasks to several developers to change different components at the same time, and no conflicts should arise.
2. Open-Closed Principle
Software entities (classes, modules, functions, etc.) must be open for extension but closed for modification.
Changing existing code is bad because it has already been tested and works. If we change the code, then we have to do regression testing. Therefore, when adding functionality, you should not change existing entities, but add new ones using composition or inheritance. Even with this approach, you may have to slightly edit the old code in order to prevent bugs or hacky code. But changing the old code should be avoided as much as possible.
Let's consider a scenario where you have an online store, and you offer a 20% discount to your preferred customers using the Discount
class. If you decide to double the discount to 40% for VIP clients, you could extend the class as follows:
# Listing [2.1]
# An example of modifying a class when adding new functionality.
class Discount:
def __init__(self, customer, price):
self.customer = customer
self.price = price
def give_discount(self):
if self.customer == 'favourite':
return self.price * 0.2
if self.customer == 'vip':
return self.price * 0.4
However, this approach violates the Open-Closed Principle, as the OCP discourages it. For example, if we want to give a new discount to a different type of customer, then this requires adding new logic. To follow the OCP principle, we will add a new class that will extend Discount
. And in this new class we implement this logic:
# Listing [2.2]
# An example of adding functionality via inheritance.
class Discount:
def __init__(self, customer, price):
self.customer = customer
self.price = price
def get_discount(self):
return self.price * 0.2
class VIPDiscount(Discount):
def get_discount(self):
return super().get_discount() * 2
If you decide to give a discount to super VIP users, it will look like this:
# Listing [2.3]
# An example of adding functionality via inheritance (2).
class SuperVIPDiscount(VIPDiscount):
def get_discount(self):
return super().get_discount() * 2
Thus, we do not modify the existing code (closed for modification), but add a new one (open for extension).
When you're designing your entity structure, it's essential to identify, at the earliest stages, those system entities that might undergo changes or expansions in the future and create appropriate abstractions for them.
Let's explore another example: we have a Weapon
class and a Character
class. In this program, the character has a weapon and can perform attacks with it.
# Listing [3.1]
# Example program, not easily extensible.
class Weapon:
def __init__(self, name, damage):
self.name = name
self.damage = damage
def attack(self):
print(f"{self.name} strikes: -{self.damage} hp")
class Character:
def __init__(self, name, weapon: Weapon):
self.name = name
self.weapon = weapon
def change_weapon(self, weapon: Weapon):
self.weapon = weapon
def attack(self):
self.weapon.attack()
sword = Weapon("Needle", 24, 3)
aria = Character("Aria", sword)
aria.attack() # Output: Needle strikes: -24 hp
Now, we've made the decision to introduce a new weapon, the bow. This requires us to modify the Weapon.attack
method and incorporate an additional type
field to expand the output logic (changing "strikes" to "shoots" for the bow).
# Listing [3.2]
# An example of adding new functionality in violation of OCP.
class Weapon:
def __init__(self, _type, name, damage):
self.type = _type
self.name = name
self.damage = damage
def attack(self):
if self.type == "striking":
print(f"{self.name} skrikes: -{self.damage} hp")
elif self.type == "shooting":
print(f"{self.name} shoots: -{self.damage} hp")
sword = Weapon("striking", "Needle", 24, 3)
aria = Character("Aria", sword)
aria.attack() # Output: Needle strikes: -24 hp
bow = Weapon("shooting", "Thread", 30, 100)
aria.change_weapon(bow)
aria.attack() # Output: Thread shoots: -30 hp
As we discussed above, this approach violates the OCP. When writing the Weapon
class, its extension for another gun types was not provided in advance. From the very beginning it was worth creating more abstract code.
# Listing [3.3]
# An example of a program that is amenable to extension.
class Attacker:
"""Interface for attacking classes."""
def attack(): raise NotImplementedError
class Weapon(Attacker):
"""Defines a general structure for weapons."""
def __init__(self, name, damage):
self.name = name
self.damage = damage
class Sword(Weapon):
"""
Inherits the structure of the weapon
and implements the attack interface.
"""
def attack(self):
print(f"{self.name} stirkes: -{self.damage} hp")
class Bow(Weapon):
def attack(self):
print(f"{self.name} shoots: -{self.damage} hp")
sword = Sword("Needle", 24, 3)
bow = Bow("Thread", 30, 100)
aria = Character("Aria", sword)
aria.attack() # Output: Needle strikes: -24 hp
aria.change_weapon(bow)
aria.attack() # Output: Thread shoots: -30 hp
Code structured in this manner is more amenable to expansion, appears cleaner, and more professional. It should be noted that if you are absolutely sure that you will not have additional functionality in the future, then it is better to stick to the KISS (Keep It Short and Simple) principle and not create additional abstractions.
3. Liskov Substitution Principle
The main idea behind the Liskov Substitution Principle is that for any class, the client should be able to use any subclass of the base class without noticing the difference between them.
And therefore without any change in the execution behavior of the program. This means that the inherited class should complement, not replace, the behavior of the parent, and that the client is completely isolated and unaware of changes in the class hierarchy.
Subtype Requirement:
Let *φ(x)** be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.*
In simpler terms, if you disrupt the logic of the parent class within the child class, you are violate the LSP principle.
# Listing [4]
# An example of a program that violates the LSP principle.
class Develpoer:
def write_code(self): ...
class Backend(Developer):
def configure_server(self): ...
class DevOps(Developer):
"""
Let's imagine a scenario where our DevOps team lacks coding skills.
"""
def monitor_resources(self): ...
def write_code(self):
"""
We alter the implementation, consequently violating the LSP.
"""
raise UnableToDo("DevOps cannot write code.")
In listing above, the DevOps
class violated the logic of his parent, thereby violating the LSP principle. According to this principle, a client who uses Developer
should be able to replace it with any child class and not break the program. In the case of a child class DevOps
, the program will raise an error.
The following example demonstrates the client's ability to use a class and its children without breaking program logic.
# Listing [5]
# An example of a program that follows the LSP principle.
from dataclasses import dataclass
@dataclass
class Position:
x: int = 0
y: int = 0
def __str__(self):
return f"({self.x}, {self.y})"
class Character:
"""Base class of characters."""
def __init__(self, name):
self.name = name
self.position = Position()
def move(self, destination: Position):
print("{name} moves from {start} to {end}".format(
name=self.name, start=self.position, end=destination
))
self.position = destination
class Human(Character):
"""Child class that follows parent's logic."""
def move(self, destination: Position):
print("{name} goes from {start} to {end}".format(
name=self.name, start=self.position, end=destination
))
self.position = destination
def buy(self):
"""Adds aditional logic."""
print("Buys an item.")
class Dragon(Character):
"""Child class that follows parent's logic."""
def move(self, destination: Position):
print("{name} flies from {start} to {end}".format(
name=self.name, start=self.position, end=destination
))
self.position = destination
def attack(self):
"""Adds aditional logic."""
print("Spews fire at the enemy.")
def move(character: Character, destination: Position):
"""
A client that uses `Character` and its subclasses
without noticing the difference.
"""
character.move(destination)
spirit = Character("Spirit")
john = Human("John")
drogon = Dragon("Drogon")
meeting_point = Position(x=300, y=250)
move(spirit, meeting_point)
move(john, meeting_point)
move(drogon, meeting_point)
# Output:
# Spirit moves from (0, 0) to (300, 250)
# John goes from (0, 0) to (300, 250)
# Drogon flies from (0, 0) to (300, 250)
As we can see, the move
function can work both with Character
and its subclasses without errors.
LSP is the basis of good object-oriented software design because it aligns with one of the fundamental principles of Object-Oriented Programming (OOP): polymorphism. The point is to create correct hierarchies such that classes derived from the base are polymorphic for their parent in relation to the methods of its interfaces. It is also interesting to note how this principle relates to the example of the previous principle. If we try to extend a class with a new incompatible class, then everything will break. Interaction with the client will be broken, and as a result, such an extension will not be possible (or, in order to make this possible, we would have to violate another principle and modify the client code, which should be closed for modification, this is highly undesirable and unacceptable).
Thoughtfully considering new classes in accordance with LSP facilitates the proper expansion of class hierarchies. Furthermore, LSP contributes to the Open-Closed Principle.
4. Interface Segregation Principle
Clients should not depend on interfaces they do not use. You shouldn't force a client to implement an interface that it does not use.
Create thin interfaces: many client-specific interfaces are better than one general-purpose interface. This principle eliminates the disadvantages of implementing large interfaces.
To illustrate this, let's take the following example. Suppose we were tasked with creating a Smartphone
. We initially created the Device
interface to accommodate it and future devices. Later, we needed to add a Laptop
that couldn't make phone calls. At this point, we should recognize that our Device
interface contradicts the Interface Segregation Principle, and it should be split. However, if we were unaware of ISP, we might have written Laptop
as shown in Listing [6.1]. Then, when the task arose to add a Phone
, we would also violate the principle. The resulting code would look like this:
# Listing [6.1]
# An example of a program that violates ISP.
# In Listing 6.*, by ... we mean a missing method implementation.
class Device:
def call(self): raise NotImplementedError
def send_file(self): raise NotImplementedError
def browse_internet(self): raise NotImplementedError
class Smartphone(Device):
def call(self): ...
def send_file(self): ...
def browse_internet(self): ...
class Laptop(Device):
def call(self):
raise BadOperation("A laptop cannot make calls.")
def send_file(self): ...
def browse_internet(self): ...
class Phone(Device):
def call(self): ...
def send_file(self):
raise BadOperation("A phone cannot send files.")
def browse_internet(self):
raise BadOperation("A phone cannot access internet.")
This is a clear illustration of the dependence of the Laptop
and Phone
clients on the Device
interface, which they only partially implement.
A nice trick is that in our business logic, a single class can implement multiple interfaces when necessary. This allows us to provide a unified implementation for all shared methods across interfaces. In Python, this is easily achieved through multiple inheritance:
# Listing [6.2]
# An example of a program that complies with ISP.
class CallDevice:
def call(self): raise NotImplementedError
class FileTransferDevice:
def send_file(self): raise NotImplementedError
class InternetDevice:
def send_file(self): raise NotImplementedError
class Smartphone(CallDevice, FileTransferDevice, InternetDevice):
def call(self): ...
def send_file(self): ...
def browse_internet(self): ...
class Laptop(FileTransferDevice, InternetDevice):
def send_file(self): ...
def browse_internet(self): ...
class Phone(CallDevice):
def call(self): ...
We now see fine-grained interfaces and eliminate methods within software entities that they do not use. This results in more predictable behavior, and the code becomes less tightly coupled.
Segregated interfaces force us to think more about our code from the client's point of view, which will lead us to less dependency and easier testing. This way, not only did we make our code better for the client, but it also made it easier for us to understand, test, and implement the code for ourselves.
5. Dependency Inversion Principle
Dependency should be on abstractions, not on specifics.
Modules at higher levels should not depend on modules at low levels. Both upper and lower level classes must depend on the same abstractions. Abstractions should not depend on details. Details must depend on abstractions.
As development progresses, there comes a point where our application predominantly consists of modules. At this stage, it becomes essential to enhance our code using dependency injection. The functionality of high-level components relies on low-level components. You can utilize inheritance or interfaces to achieve specific behaviors.
Let's look at a bad example first. Suppose we have a Post
entity, and we've assigned three programmers to implement various storage solutions for posts. Unfortunately, they didn't agree on naming conventions and created storages with different method names. This is problematic, because the entity that will use the storages to save posts is highly dependent on the specific storage implementation and will have to adapt to each repository each time they change.
# Listing [7.1]
# An example of unorganized code.
class Post:
title: str
content: str
class PostLocalStorage:
def fetch_all(self): ...
def get_one(self): ...
def save(self): ...
class PostCacheDict:
def get_all(self): ...
def get(self): ...
def set(self): ...
class PostDBStorage:
def select_all(self): ...
def select_one(self): ...
def insert(self): ...
The first step to structuring your code and getting rid of dependencies is to create a common interface for storages.
# Listing [7.2]
# An example of a common interface for storage classes.
class Storage:
def get_all(self): raise NotImplementedError
def get(self): raise NotImplementedError
def save(self): raise NotImplementedError
class PostLocalStorage(Storage):
def get_all(self): ...
def get(self): ...
def save(self): ...
# Other storages also inherit from `Storage`.
...
Now all storages use the same method names, which allows the client to use the repository without knowing its type. However, an even better approach is to introduce an abstraction and interact directly with it. This abstraction will receive a storage object and delegate calls to specific methods to the underlying storage.
# Listing [7.3]
# An example of introducing a general abstraction for storage.
class StorageClient(Storage):
def __init__(self, storage: Storage):
self.storage = storage
def get_all(self):
return self.storage.get_all()
def get(self, *args):
return self.storage.get(*args)
def save(self, *args):
return self.storage.save(*args)
With this approach:
- the client always works with the storage abstraction
StorageClient
; - it offers a clear and transparent interface;
- the client remains independent of the specific storage implementation.
Note: storage client !=
StorageClient
(storage client is the one who saves posts usingStorageClient
).
If we reflect the final program on the definition, then the client now depends on the StorageClient
abstraction rather than on specific implementations like PostLocalStorage
, and so on. The top-level module (storage client) is independent of lower-level modules (storage implementations). Both upper (storage client) and lower (storage implementation) classes depend on the same abstraction - StorageClient
. StorageClient
does not depend on storage implementation details, it simply delegates the execution of common interface methods. The implementation details depend on and are guided by StorageClient
.
By incorporating these principles into your software development process, you can enhance the quality of your codebase, making it easier to scale and adapt to evolving requirements. Embracing SOLID principles is not just a best practice; it's a path to crafting software that stands the test of time.
Posted on November 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.