SOLID Design Principles With Helpful Examples

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:

  • Enhanced Readability – developers can easily understand the existing codebase and subsequently, make changes to it.
  • Better Scalability – it is easier to bring about extensions to the software without introducing bugs in the existing system functionalities.
  • Reduced Coupling – working with a loosely coupled codebase will help avoid the ripple effects that a new piece of code might otherwise introduce to the system.

SOLID stands for:

  • The Single-Responsibility Principle (SRP)
  • The Open-Closed Principle (OCP)
  • The Liskov Substitution Principle (LSP)
  • The Interface Segregation Principle (ISP)
  • The Dependency Inversion Principle (DIP)

In this article, we will be delving into the world of SOLID Principles, understanding these intricate concepts using Python.

The Single-Responsibility Principle (SRP)

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

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

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.

Interface Segregation 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.

Dependency Inversion Principle

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. 


Written by
I am a skilled full-stack developer with extensive experience in creating and deploying large and small-scale applications. My expertise spans front-end and back-end technologies, along with database management and server-side programming.

Share on:

Related Posts

Tags: OOP,