Last updated on June 7, 2023
SOLID, an acronym for understanding the first five object-oriented design principles (OOD), is critical to developing robust software. These principles were originally determined by Robert C. Martin and have since gained significant popularity.
Keeping the SOLID principles under consideration while designing and developing software has an array of diverse advantages:
SOLID stands for:
In this article, we will be delving into the world of SOLID Principles, understanding these intricate concepts using Python.
The Single-Responsibility Principle states that any component (generally a class) of a system should be developed to perform a single functionality. Subsequently, there should be only one reason for it to incorporate any modifications.
Many times, we put too much burden onto a single software component. This leads to unnecessary complexities over time, making it difficult to alter and maintain the code.
Let’s understand this by way of an example.
class Employee:
def __init__(self, employeeName: str, employeeClearanceLevel: int):
self.employeeName = employeeName
self.employeeClearanceLevel= employeeClearanceLevel
def getEmployeeName(self) -> str:
return self.employeeName
def getEmployeeClearanceLevel(self) -> int:
return self.employeeClearanceLevel
def storeEmployeeInDatabase(self):
pass
We have an Employee Class which takes the Employee Name and its clearance level ID to form an object.
Note the storeEmployeeInDatabase() method within the same class.
Do you see the problem here?
Two completely different functionalities are being performed within the same class – Employee properties are being set in the same class where the data is being saved to the database which violates the SRP principle.
To fix this, we can put the functionalities into two separate classes like this:
class Employee:
def __init__(self, employeeName: str, employeeClearanceLevel: int):
self.employeeName = employeeName
self.employeeClearanceLevel= employeeClearanceLevel
def getEmployeeName(self) -> str:
return self.employeeName
def getEmployeeClearanceLevel(self) -> int:
return self.employeeClearanceLevel
class EmployeeDatabase:
def getEmployeeDetails(self, Employee):
pass
def storeEmployeeInDatabase(self):
pass
The Open-Closed Principle states that any software entity (classes or functions) should be open for extension but not modification.
This means it suggests you add new functionality without modifying existing code which reduces the chances of bugs in your application.
Let’s understand this by extending the previous example.
Say, we have a function getEmployeeRole(). Depending on the Employee Clearance level, we will set the relevant employee role.
def getEmployeeRole(Employee):
if Employee.getEmployeeClearanceLevel() == 1:
print(str(Employee.getEmployeeName()) + ' - Developer')
elif Employee.getEmployeeClearanceLevel() == 2:
print(str(Employee.getEmployeeName()) + ' - Tester')
else:
print(str(Employee.getEmployeeName()) + ' - Unknown')
Note how, whenever a new role is defined, we are required to modify the getEmployeeRole() function which does not align with our OCP principle.
To fix this, we will introduce a virtual function that may be extended whenever a new role is introduced within the organization.
Let’s see how:
class Employee:
def __init__(self, employeeName: str, employeeClearanceLevel: int):
self.employeeName = employeeName
self.employeeClearanceLevel= employeeClearanceLevel
def getEmployeeName(self) -> str:
return self.employeeName
def getEmployeeClearanceLevel(self) -> int:
return self.employeeClearanceLevel
def EmployeeRole(self):
return 'Unknown'
class Developer(Employee):
def employeeRole(self):
return 'Developer'
class Tester(Employee):
def employeeRole(self):
return 'Tester'
def getEmployeeRole(Employees: list):
for Employee in Employees:
print(Employee.getEmployeeName() + ' - ' + Employee.employeeRole())
Dev = Developer("John",1)
Test = Tester("Ali",2)
employees = [Dev,Test]
getEmployeeRole(employees)
In the example above, we create separate sub-classes (Developer/Tester) inheriting the properties of the super Employee class. This way, whenever a new role is introduced, we are simply required to extend the Employee class and add the relevant functionality without having to modify the existing code.
The Liskov Substitution Principle is an esoteric concept to comprehend and understand for most of the audience. Largely, because there is no standard definition for it.
In a summary, the LSP rule states that sub-classes should be substitutable for their respective superclasses.
This means that any change in the subclasses should not affect the normal functioning of the code.
For example, look at the following code snippet:
class Employee:
def __init__(self,employeeName: str, employeeClearanceLevel: int):
self.employeeName = employeeName
self.employeeClearanceLevel= employeeClearanceLevel
def employeeRole(self):
pass
class Developer(Employee):
def employeeRole(self):
return 'Developer'
def developerRequiredExperience(self):
return '5 years'
class Tester(Employee):
def employeeRole(self):
return 'Tester'
def testerRequiredExperience(self):
return '2 years'
def employeeRequiredExperience(Employees : list):
for Employee in Employees:
if isinstance(Employee, Developer):
print(Employee.developerRequiredExperience())
elif isinstance(Employee, Tester):
print(Employee.testerRequiredExperience())
In the code above, we are required to check the object instance to call the respective function.
This means that whenever there’s a change/extension to the existing system, it will hinder the usual code flow because some extra modification to the employeeRequiredExperience() function will be necessary.
To ensure that the code above follows the LSP principle, let us change the logic a bit:
class Employee:
def __init__(self,employeeName: str, employeeClearanceLevel: int):
self.employeeName = employeeName
self.employeeClearanceLevel= employeeClearanceLevel
def requiredExperience(self):
pass
class Developer(Employee):
def requiredExperience(self):
return '5 years'
class Tester(Employee):
def requiredExperience(self):
return '2 years'
def employeeRequiredExperience(Employees : list):
for Employee in Employees:
print(Employee.requiredExperience())
Here, we have placed a method requiredExperience() in the superclass. All the sub-classes are required to implement the method – this means that there is no code modification for the existing code base. Hence, we can say that we agree with the LSP principle.
According to the Interface Segregation Principle, software component interfaces should be designed to enforce a developer to implement functions according to the client’s specifications.
Small, fine-grained interfaces may be a good approach – the ISP principle discourages the implementation of big interfaces.
Take the following code for example:
class IEmployee:
def getDeveloperExperience(self):
raise NotImplementedError
def getTesterExperience(self):
raise NotImplementedError
def getBAExperience(self):
raise NotImplementedError
class Developer(IEmployee):
def getDeveloperExperience(self):
pass
def getTesterExperience(self):
pass
def getBAExperience(self):
pass
class Tester(IEmployee):
def getDeveloperExperience(self):
pass
def getTesterExperience(self):
pass
def getBAExperience(self):
pass
class BA(IEmployee):
def getDeveloperExperience(self):
pass
def getTesterExperience(self):
pass
def getBAExperience(self):
pass
The subclasses (Developer/Tester/BA) are forced to implement functions irrelevant to their scope of functionality. The above code looks funny – notice how a Developer class is implementing methods related to the Tester and the BA classes which logically does not even make much sense.
This means that the code above clearly defies the ISP rule which asks us to let the client focus on the function it is expected to perform.
Let’s correct this:
class IEmployee:
def getExperience(self):
raise NotImplementedError
class Developer(IEmployee):
def getExperience(self):
pass
class Tester(IEmployee):
def getExperience(self):
pass
class BA(IEmployee):
def getExperience(self):
pass
In the code above, we have implemented an interface relevant to each sub-class by inheriting the same function getExperience() for each sub-class, keeping the separation of concerns intact.
The last of the SOLID Principles is the Dependency Inversion Principle.
It says that neither should the high-level components depend on Low-Level components nor should details depend upon abstractions. Both should depend on abstractions.
Take a look at the following code:
class RockMusic:
def fetchRockMusic(self):
print("Rock Music list")
class PlayMusic:
def __init__(self, p: RockMusic):
self.RockMusic = p
def pressPlay(self):
self.RockMusic.fetchRockMusic()
music = RockMusic()
Play = PlayMusic(music)
Play.pressPlay()
We have defined a class RockMusic which has a function fetchRockMusic() – this function simply prints a line of text of course, since this is just written to demonstrate a concept.
Note how the PlayMusic class directly uses the RockMusic object to use in its pressPlay function.
This has created a strongly coupled code because we have created a dependency of the PlayMusic class on RockMusic.
We have, however, just mentioned that the DIP principle asks us to not create such dependencies while writing code – in the world of programming, this is considered a bad approach.
But, why?
This is because this code will never be generic – suppose you want to add the metal music type to this code. How will you achieve this? With this approach, you will add another class MetalMusic and then use the same function to incorporate both types of music into the system.
This means that when we write code, it should not be tightly coupled. Instead, it should make use of the OOP principle of abstraction.
Python does not directly support abstract base classes but exposes a module that may be used to implement abstraction.
Let’s first create an abstract class:
from abc import ABC, abstractmethod
class Music(ABC):
@abstractmethod
def fetchMusic(self):
pass
The abc module in Python helps implement an abstract base class. We have created a Music class in the code snippet above.
Now, we can use this class to restructure our code:
class RockMusic(Music):
def fetchMusic(self):
print("Rock Music list")
class PlayMusic:
def __init__(self, p: Music):
self.Music = p
def pressPlay(self):
self.Music.fetchMusic()
music = RockMusic()
Play = PlayMusic(music)
Play.pressPlay()
Note how we use the generic Music class instance in the PlayMusic class and now, no matter how many music types we need to add, the code will work for all music types.
This code will achieve the same behavior as the previous approach but in this case, we have removed the dependency of the PlayMusic class on the RockMusic one. It is, instead dependent on an abstract Music class.
Summing Up
In this article, I have tried to cover the 5 SOLID principles in detail with examples specific to Python but these principles apply to the coding world in general and so can be implemented in most programming languages. As a developer, you need to keep these in mind while writing code to get clean, decoupled code that follows high-level coding design principles.