Introduction
The goal of this article is to demystify SOLID, which outlines five design principles in object-oriented programming (OOP). We will begin by discussing what SOLID encompasses.
Design principles serve as high-level design guidelines for developed applications. However, they do not provide guidance on the specific methods and techniques for their implementation.
The goal of SOLID principles is to provide a foundation for building robust, maintainable, and scalable software. They guide developers in creating systems that are easier to understand, test, and extend, ultimately leading to higher-quality software that can adapt to changing requirements.
SOLID Principles
- S — SRP — Single Responsibility Principle
- O — OCP — Open Closed Principle
- L — LSP — Liskov Substitution Principle
- I — ISP — Interface Segregation Principle
- D — DIP — Dependency Inversion Principle
Single Responsibility Principle (SRP)
Each class should have only a reason to change. Each class should be responsible for a single, well-defined task. Also, everything within the class should be linked to the task.
We can apply this principle not only to classes, but also to broader context such as modules, microservices, and so forht. The classes, modules, and microservices with a single responsibility are easier to code, less fragile, readable, and easier to manage compared to those that serve multiple purposes. This has the benefit of increasing the speed of development while reducing the number of bugs.
Let me try to explain with a brief example before we delve into the code snippet. Let’s define a class to hold personnel information. When there is a change in the calculation of personnel salary, the class will also require modification to reflect that change. Besides, if the class needs to be changed when there is a change in the database personnel schema, then we have at least two reasons for the class to modify. Basically what is wanted is to avoid situations that will require multiple changes in the class.
This code snippet is an example of violating SRP. The FileManager class has two distinct responsibilities: reading and writing from a file, and compressing and decompressing a file.
Violating SRP
class FileManager:
def __init__(self, filename):
self.path = filename
def read(self, encoding="utf-8"):
pass
def write(self, data, encoding="utf-8"):
pass
def compress(self):
pass
def decompress(self):
pass
The next code snippet demonstrates a good example of SRP by separating the FileManager class into dedicated classes for reading/writing and compression/decompression.
Following SRP
class FileManager:
def __init__(self, filename):
self.path = filename
def read(self, encoding="utf-8"):
pass
def write(self, data, encoding="utf-8"):
pass
class ZipFileManager:
def __init__(self, filename):
self.path = filename
def compress(self):
pass
def decompress(self):
pass
Open Closed Principle (OCP)
The aim of this principle is to extend the features offered by a class or a module without altering the base code. Basically, classes, modules etc. should be open for extension and close for modification.
Therefore, we should focus on writing codes that do not require rewriting or modification when requirements change. Not altering the existing code, in other words, refraining from intervening in the code that has been previously written and proven to work successfully , reduces the likelihood of encountering errors and potential problems.
Examples of OCP include tools like Visual Studio Code, most IDEs and Notepad. It is possible to add and use a wide variety plugins to these applications without reinstalling the applications themselves. In this types of plugins architectures, plugins can be added and used without the need to modify the code architecture of the application. This is a good example of a use case that is open to extension and does not require modification of pre-written code.
Violating OCP
from math import pi
class Shape:
def __init__(self, shape_type, **kwargs):
self.shape_type = shape_type
if self.shape_type == "rectangle":
self.width = kwargs["width"]
self.height = kwargs["height"]
elif self.shape_type == "circle":
self.radius = kwargs["radius"]
def calculate_area(self):
if self.shape_type == "rectangle":
return self.width * self.height
elif self.shape_type == "circle":
return pi * self.radius**2
Following OCP
from abc import ABC, abstractmethod
from math import pi
class Shape(ABC):
@abstractmethod
def calculate_area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def calculate_area(self):
return pi * self.radius**2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side**2
Liskov Substitution Principle (LSP)
Simply put, Liskov substitution principle emphasizes that subclasses should inherit the behavior of their base classes. This means code written to work with a base class object should function correctly when using a subclass object, as long as the subclass methods fulfill the same purpose.
In other words, if you have a piece of code that works with a certain type of object (baseclass), it should also work seamlessly with any objects of its subclasses, as long as those subclasses adhere to the same behavioral contract.
Violating LSP
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Rectangle):
def __setattr__(self, key, value):
if key in ("width", "height"):
self.__dict__["width"] = value
self.__dict__["height"] = value
This code is a bad example of the LSP because the Square class violates the expected behavior of its parent class Rectangle in terms of setting width and height.
Inconsistent Setter Behavior:
-
The
Rectangleclass has separate setters forwidthandheightin its constructor. -
The
Squareclass overrides the default__setattr__method to ensure bothwidthandheightare always set to the same value (side).
Unexpected Behavior:
-
Code expecting a
Rectangleobject might set thewidthandheightindependently. -
However, using a
Squareobject with this code would result in bothwidthandheightbeing set to the same value (side), even if the programmer intended them to be different. -
This unexpected behavior breaks the LSP principle because a
Squareobject doesn't behave exactly as aRectangleobject when it comes to setting dimensions.
Following LSP
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def calculate_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side ** 2
The Shape class acts as a blueprint for shape objects, defining the core behavior (area calculation) that subclasses must possess. Subclasses like Rectangle and Square are required to implement calculate_area(), ensuring consistency. This design allows for code that operates on Shape objects to work seamlessly with any subclass instance, promoting code reusability.
def process_shape(shape: Shape):
area = shape.calculate_area()
print("Area:", area)
rectangle = Rectangle(4, 5)
square = Square(3)
process_shape(rectangle)
# Will print "Area: 20"
process_shape(square)
# Will print "Area: 9"
The process_shape function can handle both Rectangle and Square objects without modification, demonstrating LSP in action.
Interface Segregation Principle (ISP)
Clients mustn’t be obliged to implement interfaces and methods that are unnecessary for their purposes. The objective of ISP is to break down single general purpose interface into smaller and more specific ones, so that clients only need to know about the interfaces they are interested in.
It’s recommended not to force classes, modules and components to be bound up with, or even know, methods they do not need. The aim is to keep abstractions as hidden as possible. Using small, custom interfaces helps minimize dependencies and supports a flexible, stable and testable architecture.
Violating ISP
from abc import ABC, abstractmethod
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
@abstractmethod
def fax(self, document):
pass
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
pass
The Printer interface defines three methods: print, fax, and scan. This implies that any class implementing Printer (like OldPrinter and ModernPrinter) must provide implementations for all three methods, even if they don't have that functionality.
Following ISP
from abc import ABC, abstractmethod
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
class Fax(ABC):
@abstractmethod
def fax(self, document):
pass
class Scanner(ABC):
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
pass
class NewPrinter(Printer, Fax, Scanner):
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
pass
This code exemplifies the ISP by defining three distinct interfaces: Printer, Fax, and Scanner. Each interface caters to a specific functionality (printing, faxing, or scanning). This separation allows for greater flexibility in implementing these functionalities.
Dependency Inversion Principle (DIP)
The DIP advocates for the decoupling of high-level and low-level modules within a system. According to DIP, high-level modules, which contain the core logic and functionality of an application, should not depend on low-level modules, which are more about utility and implementation details. Instead, both should rely on abstractions, such as interfaces or abstract classes.
Key Aspects of DIP:
-
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.
The key issue here lies in the tight coupling between high-level modules (e.g., business logic) and low-level modules (e.g., libraries or APIs). When high-level modules depend directly on low-levels modules, any changes to low-level modules can have direct effects on them and force them to change. In such dependency designs, it becomes difficult to reuse the high-level modules. Nonetheless, when the high-level modules are independent of the low-level modules, they can be more easily and simply reused or moved.
This summarizes the logic behind the dependency inversion principle. By adhering to DIP, software systems become more resilient to changes. When low-level modules need modification, high-level modules remain unaffected, facilitating easier maintenance and scalability.
Violating DIP
class Service:
def send_email(self, message):
print(f"Sending email: {message}")
class OrderProcessor:
def __init__(self):
self.email_service = Service()
def process_order(self, order):
self.email_service.send_email(f"Order processed: {order}")
In this example, the OrderProcessor class relies on the Service class and its specific implementation. This means the two classes are tightly coupled, which can result in scalability problems. What if you want your app to be able to send a SMS message. How would you do that?
You may think of adding a new method to Service to send a SMS message. However, that will also require you to modify OrderProcessor, which should be closed to modification, according to the open-closed principle.
Following DIP
from abc import ABC, abstractmethod
class NotificationService(ABC):
@abstractmethod
def send_notification(self, message):
pass
class EmailService(NotificationService):
def send_notification(self, message):
print(f"Sending email: {message}")
class SMSService(NotificationService):
def send_notification(self, message):
print(f"Sending SMS: {message}")
class OrderProcessor:
def __init__(self, notification_service: NotificationService):
self.notification_service = notification_service
def process_order(self, order):
self.notification_service.send_notification(f"Order processed: {order}")
In this example, the OrderProcessor class does not depend on a specific implementation of the NotificationService. Instead, it depends on the NotificationService interface, allowing for easy substitution of different notification mechanisms, thus adhering to the Dependency Inversion Principle.
Conclusion
By adhering to SOLID principles, we can create systems that are robust, maintainable, and scalable, ensuring high-quality software that meets changing requirements. These principles provide a roadmap for better software design and architecture.