Understanding SOLID Principles: A Beginner's Guide
Every day, new requirements for our application arise, and we need to make improvements to meet them while keeping our code easy to read and maintain. If our application is built using Object-Oriented design, we can follow the SOLID principles to achieve this.
SOLID is an acronym for five principles of software design using Object-Oriented Programming (OOP). These principles are not linked to any specific programming language, so they work regardless of the language you use.
These principles were introduced by Robert C. Martin in a paper titled Design Principles and Design Patterns.
The acronym SOLID was later proposed by Michael Feathers and stands for:
[S]ingle Responsibility Principle
[O]pen-Closed Principle
[L]iskov Substitution Principle
[I]nterface Segregation Principle
[D]ependency Inversion Principle
These principles help software developers design and write Object-Oriented programs with low coupling and high cohesion. They also make it easier to maintain and refactor the code when needed.
Single Responsibility Principle
This principle states:
There should never be more than one reason for a class to change
We say that a class must be cohesive, meaning it should have only one responsibility.
Why is it a problem if a class has more than one responsibility?
When a class has multiple responsibilities, each one can change independently. This makes the class more coupled, meaning changes can have a bigger impact on other classes that inherit from it.
For example, let's look at the following code:
class Server:
def create_connection(self):
pass
def check_connection(self):
pass
def close_connection(self):
pass
def send_package(self):
pass
def receive_package(self):
pass
In the Server
class, we see it has two responsibilities: managing connections (create, close, and check) and managing data packages (send and receive). As a result, all classes that inherit from it also take on these two behaviors.
Applying the principle
Using the Single Responsibility Principle, we can split a class into two separate classes, each with its own responsibility: Connection managers and Package managers.
class ConnectionManager:
"""Manager connection only"""
def create_connection(self):
pass
def check_connection(self):
pass
def close_connection(self):
pass
class PackageManager:
"""Manager send and receive data packages only"""
def send_package(self):
pass
def receive_package(self):
pass
The Single Responsibility Principle is one of the hardest principles to apply because the idea of a class's responsibility can differ among software developers. They decide if a behavior fits within the class's scope. To apply this principle effectively, we should always focus on the problem we want to solve and the software architecture we use in our design.
Open-Close Principle
We must always be careful when inheriting and changing class source code to avoid breaking code that already works. The Open-Close Principle helps us prevent this.
This principle states:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Let's break down each part of this quote:
Open to extension means that a class's behavior can be expanded. We should be able to create a child class and extend a method to adjust its behavior based on the needs of our application.
Closed to modification means that a class's source code cannot be changed. No one is allowed to alter the original class's source code to meet new application requirements. Changing the class's source code can affect other functionalities that rely on this class's behavior.
For example, let's examine the following classes that represent the geometric shapes: square and circle
class Square:
def __init__(self, lenght):
super().__init__()
self.lenght = lenght
class Circle:
def __init__(self, radius):
super().__init__()
self.radius = radius
We also have a class that calculates the area of these shapes and returns the result.
import math
class ShapeAreaCalculator:
def calc_area(self, shape):
"""Calculate area of shape"""
area = 0
if isinstance(shape, Square):
area = shape.lenght ** 2
elif isinstance(shape, Circle):
area = math.pi * shape.radius ** 2
return area
The problem with the ShapeAreaCalculator
class is that whenever a new geometric shape is added, we need to add another if
statement to check the shape
. This increases the size and complexity of the calc_area
method. In other words, the ShapeAreaCalculator
class is not closed to modification.
Applying the principle
To start fixing the issue, let's enhance the geometric shape classes by creating a base class called Shape
. This class will have a method named area
, which will be extended by all geometric shapes that inherit from it.
class Shape:
def area(self):
pass
Let’s update the Square
and Circle
classes, make them inherit from Shape
class.
class Square(Shape):
def __init__(self, lenght):
super().__init__()
self.lenght = lenght
def area(self):
return self.lenght ** 2
import math
class Circle(Shape):
def __init__(self, radius):
super().__init__()
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
Now, every new geometric shape must inherit from the Shape
class and implement its own area
method.
All shape classes are now open to extension but closed to modification. This means they won't need changes when a new shape is added.
Finally, let's update the calc_area
method so that it doesn't need changes when a new shape is added.
class ShapeAreaCalculator:
def calc_area(self, shape):
"""Calcule area of shape"""
area = shape.area()
return area
This way, the calc_area
method doesn't need changes, no matter how many new geometric shapes are added to our application.
Liskov Substitution Principle
This principle states:
Functions that use pointers or references to a base class must be able to use objects from derived classes without needing to know it.
It means that all children of our base class must have the same methods. If a class S
is a child of class T
, then all objects of class T
can be replaced by objects of class S
without needing to change our application.
Applying the principle
For example, consider a class named Shape
. All classes that inherit from Shape
must have an area
method.
class Shape:
def area(self):
raise NotImplementedError("Subclasse should implement this")
When a class inherits from the Shape
class, it must implement its own area
method; otherwise, the application will raise a NotImplementedError
.
Now, let's consider a class named ShapeAreaCalculator
.
class ShapeAreaCalculator:
def calc_area(self, shape):
"""Calcule area of shape"""
return shape.area()
The calc_area
method takes a shape
object as a parameter without needing to know its specific class. We can substitute the shape
object with any object from a child class of Shape
, and the application will still function properly.
The Liskov Substitution Principle is a fundamental concept for implementing Polymorphism in object-oriented software.
Interface Segregation Principle
This principle states:
A class should never be forced to implement an interface or method that it will not use.
This situation usually occurs when we create classes or interfaces that are too generic, aiming to cover all possible uses of our application.
Applying the principle
For example, let's look at our Shape
class:
class Shape:
def area(self):
raise NotImplementedError("Subclasse should implement this")
This class is usually used as the base class for Square
and Circle
classes, which are geometric shapes that implement the area
method:
class Square(Shape):
def __init__(self, lenght):
super().__init__()
self.lenght = lenght
def area(self):
return self.lenght ** 2
class Circle(Shape):
def __init__(self, radius):
super().__init__()
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
Now, let's say we want to enhance our Shape
class to include 3D geometric shapes like cubes and spheres. To do this, we add a new method called volume
to the Shape
class.
class Shape:
def area(self):
raise NotImplementedError("Subclasse should implement this")
def volume(self):
raise NotImplementedError("Subclasse should implement this")
Consider the new method volume
. All our child classes will need to implement it. However, it doesn't make sense for Square
and Circle
to have a volume
method because they are 2D geometric shapes. They won't use it.
The Interface Segregation Principle advises us to add only methods that our classes will actually use. To solve this issue, let's create a new base class called Solid
. This class will represent 3D geometric shapes, making it logical to include a volume
method.
class Solid:
def volume(self):
raise NotImplementedError("Subclasse should implement this")
Dependency Inversion Principle
This principle states:
Only depend on abstractions, not on implementations.
In practical terms, no class should inherit from a concrete class, and no method should override an already implemented method. Our classes should always extend from interfaces or abstract classes.
Applying the principle
Take a look at the following classes:
class Shape:
"Base class"
def area(self):
raise NotImplementedError("Subclasse should implement this")
class Square(Shape):
"Square shape"
def __init__(self, lenght):
super().__init__()
self.lenght = lenght
def area(self):
return self.lenght ** 2
class Circle(Shape):
"Circle shape"
def __init__(self, radius):
super().__init__()
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
The Square
and Circle
classes override the area
method of the Shape
base class, which is an abstraction. We could make the Square
class inherit from the Circle
class.
class Square(Circle):
def __init__(self, radius, lenght):
super().__init__(radius)
self.lenght = lenght
def area(self):
return self.lenght ** 2
This type of inheritance breaks the Dependency Inversion Principle because Circle
is a concrete class. This approach leads to some problems:
Our Square class will have a radius attribute, which doesn't make any sense for the Square class since the attribute represents the radius of a circle. When creating a Square object, we will need to provide not only the length of the square's sides but also a value for radius, even though it will never be used.
We have to override the implementation of the area method from the Circle class, because if we don't, the Square class will use the area implementation from the parent Circle class, returning the wrong area calculation. This is because both the area formula and the value of the radius attribute (as seen in item 1) will be incorrect.
Based on the example above, we can conclude that we should always use abstract classes and interfaces whenever possible. This way, we avoid inheriting behaviors and attributes that are already implemented.
Conclusion
In conclusion, the SOLID principles offer a strong framework for designing and maintaining object-oriented software, with each principle focusing on a specific part of software design. By using these principles, developers can make their code less complex, improve readability, and make maintenance and scaling easier. Following SOLID principles leads to more reliable and efficient software development practices.
References
Martin, Robert C. (2014). "The Single Responsibility Principle". The Clean Code Blog
Martin, Robert C. (1996). "The Open-Closed Principle", C++ Report
Martin, Robert C. (March 1996). "The Liskov Substitution Principle" (PDF). C++ Report. Archived from the original (PDF) on 2015-11-28
Martin, Robert C. (June 1996). “The Interface Segregation Principle”. C++ Report
Martin, Robert C. (May 1996). “The Dependency Inversion Principle”. C++ Report
Subscribe to my newsletter
Read articles from Michell Stuttgart directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Michell Stuttgart
Michell Stuttgart
I'm a backend Python developer, open source enthusiast, husband, Christian, and happy Linux user working from Brazil. brazil