SOLID is a mnemonic acronym for class design in object-oriented programming. The principles institute practices that help develop good programming habits and maintainable code.
By considering code maintenance and extensibility in the long run, SOLID principles enrich the Agile code development environment. Accounting for and optimizing code dependencies helps create a more straightforward and organized software development lifecycle.
What Are SOLID Principles?
SOLID represents a set of principles for designing classes. Robert C. Martin (Uncle Bob) introduced most design principles and coined the acronym.
SOLID stands for:
- Single-Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
SOLID principles represent a collection of best practices for software design. Each idea represents a design framework, leading to better programming habits, improved code design, and fewer errors.
SOLID: 5 Principles Explained
The best way to understand how SOLID principles work is through examples. All principles are complementary and apply to individual use cases. The order in which the principles are applied is unimportant, and not all principles are applicable in every situation.
Each section below provides an overview of each SOLID principle in the Python programming language. The general ideas of SOLID apply to any object-oriented language, such as PHP, Java, or C#. Generalizing the rules makes them applicable to modern programming approaches, such as microservices.
Single-Responsibility Principle (SRP)
The single-responsibility principle (SRP) states: “There should never be more than one reason for a class to change.”
When changing a class, we should only change a single functionality, which implies every object should have only one job.
As an example, look at the following class:
# A class with multiple responsibilities
class Animal:
# Property constructor
def __init__(self, name):
self.name = name
# Property representation
def __repr__(self):
return f'Animal(name="{self.name}")'
# Database management
def save(animal):
print(f'Saved {animal} to the database')
if __name__ == '__main__':
# Property instantiation
a = Animal('Cat')
# Saving property to a database
Animal.save(a)
When making any changes to the save()
method, the change happens in the Animal
class. When making property changes, the modifications also occur in the Animal
class.
The class has two reasons to change and violates the single-responsibility principle. Even though the code works as expected, not respecting the design principle makes the code harder to manage in the long run.
To implement the single-responsibility principle, notice the example class has two distinct jobs:
- Property management (the constructor and
get_name()
). - Database management
(save()
).
Therefore, the best way to address the issue is to separate the database management method into a new class. For example:
# A class responsible for property management
class Animal:
def __init__(self, name):
self.name = name
def __repr__(self):
return f'Animal(name="{self.name}")'
# A class responsible for database management
class AnimalDB:
def save(self, animal):
print(f'Saved {animal} to the database')
if __name__ == '__main__':
# Property instantiation
a = Animal('Cat')
# Database instantiation
db = AnimalDB()
# Saving property to a database
db.save(a)
Changing the AnimalDB
class does not affect the Animal
class with the single-responsibility principle applied. The code is intuitive and easy to modify.
Open-Closed Principle (OCP)
The open-closed principle (OCP) states: “Software entities should be open for extension but closed for modification.”
Adding functionalities and use-cases to the system should not require modifying existing entities. The wording seems contradictory – adding new functionalities requires changing existing code.
The idea is simple to understand through the following example:
class Animal:
def __init__(self, name):
self.name = name
def __repr__(self):
return f'Animal(name="{self.name}")'
class Storage:
def save_to_db(self, animal):
print(f'Saved {animal} to the database')
The Storage
class saves the information from an Animal
instance to a database. Adding new functionalities, such as saving to a CSV file, requires adding code to the Storage
class:
class Animal:
def __init__(self, name):
self.name = name
def __repr__(self):
return f'Animal(name="{self.name}")'
class Storage:
def save_to_db(self, animal):
print(f'Saved {animal} to the database')
def save_to_csv(self,animal):
printf(f’Saved {animal} to the CSV file’)
The save_to_csv
method modifies an existing Storage
class to add the functionality. This approach violates the open-closed principle by changing an existing element when a new functionality appears.
The code requires removing the general-purpose Storage
class and creating individual classes for storing in specific file formats.
The following code demonstrates the application of the open-closed principle:
class DB():
def save(self, animal):
print(f'Saved {animal} to the database')
class CSV():
def save(self, animal):
print(f'Saved {animal} to a CSV file')
The code complies with the open-closed principle. The full code now looks like this:
class Animal:
def __init__(self, name):
self.name = name
def __repr__(self):
return f'"{self.name}"'
class DB():
def save(self, animal):
print(f'Saved {animal} to the database')
class CSV():
def save(self, animal):
print(f'Saved {animal} to a CSV file')
if __name__ == '__main__':
a = Animal('Cat')
db = DB()
csv = CSV()
db.save(a)
csv.save(a)
Extending with additional functionalities (such as saving to an XML file) does not modify existing classes.
Liskov Substitution Principle (LSP)
The Liskov substitution principle (LSP) states: “Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.”
The principle states that a parent class can substitute a child class without any noticeable changes in functionality.
Check out the file writing example below:
# Parent class
class FileHandling():
def write_db(self):
return f'Handling DB'
def write_csv(self):
return f'Handling CSV'
# Child classes
class WriteDB(FileHandling):
def write_db(self):
return f'Writing to a DB'
def write_csv(self):
return f"Error: Can't write to CSV, wrong file type."
class WriteCSV(FileHandling):
def write_csv(self):
return f'Writing to a CSV file'
def write_db(self):
return f"Error: Can't write to DB, wrong file type."
if __name__ == "__main__":
# Parent class instantiation and function calls
db = FileHandling()
csv = FileHandling()
print(db.write_db())
print(db.write_csv())
# Children classes instantiations and function calls
db = WriteDB()
csv = WriteCSV()
print(db.write_db())
print(db.write_csv())
print(csv.write_db())
print(csv.write_csv())
The parent class (FileHandling
) consists of two methods for writing to a database and a CSV file. The class handles both functions and returns a message.
The two child classes (WriteDB
and WriteCSV
) inherit properties from the parent class (FileHandling
). However, both children throw an error when attempting to use the inappropriate write function, which violates the Liskov Substitution principle since the overriding functions don't correspond to the parent functions.
To following code resolves the issues:
# Parent class
class FileHandling():
def write(self):
return f'Handling file'
# Child classes
class WriteDB(FileHandling):
def write(self):
return f'Writing to a DB'
class WriteCSV(FileHandling):
def write(self):
return f'Writing to a CSV file'
if __name__ == "__main__":
# Parent class instantiation and function calls
db = FileHandling()
csv = FileHandling()
print(db.write())
print(csv.write())
# Children classes instantiations and function calls
db = WriteDB()
csv = WriteCSV()
print(db.write())
print(csv.write())
The child classes correctly correspond to the parent function.
Interface Segregation Principle (ISP)
The interface segregation principle (ISP) states: “Many client-specific interfaces are better than one general-purpose interface.”
In other words, more extensive interaction interfaces are split into smaller ones. The principle ensures classes only use the methods they need, reducing overall redundancy.
The following example demonstrates a general-purpose interface:
class Animal():
def walk(self):
pass
def swim(self):
pass
class Cat(Animal):
def walk(self):
print("Struts")
def fly(self):
raise Exception("Cats don't swim")
class Duck(Animal):
def walk(self):
print("Waddles")
def swim(self):
print("Floats")
The child classes inherit from the parent Animal
class, which contains walk
and fly
methods. Although both functions are acceptable for certain animals, some animals have redundant functionalities.
To handle the situation, split the interface into smaller sections. For example:
class Walk():
def walk(self):
pass
class Swim(Walk):
def swim(self):
pass
class Cat(Walk):
def walk(self):
print("Struts")
class Duck(Swim):
def walk(self):
print("Waddles")
def swim(self):
print("Floats")
The Fly
class inherits from the Walk
, providing additional functionality to appropriate child classes. The example satisfies the interface segregation principle.
Adding another animal, such as a fish, requires atomizing the interface further since fish can’t walk.
Dependency Inversion Principle (DIP)
The dependency inversion principle states: “Depend upon abstractions, not concretions.”
The principle aims to reduce connections between classes by adding an abstraction layer. Moving dependencies to abstractions makes the code robust.
The following example demonstrates class dependency without an abstraction layer:
class LatinConverter:
def latin(self, name):
print(f'{name} = "Felis catus"')
return "Felis catus"
class Converter:
def start(self):
converter = LatinConverter()
converter.latin('Cat')
if __name__ == '__main__':
converter = Converter()
converter.start()
The example has two classes:
LatinConverter
uses an imaginary API to fetch the Latin name for an animal (hardcoded “Felis catus
” for simplicity).Converter
is a high-level module that uses an instance ofLatinConverter
and its function to convert the provided name. TheConverter
heavily depends on theLatinConverter
class, which depends on the API. This approach violates the principle.
The dependency inversion principle requires adding an abstraction interface layer between the two classes.
An example solution looks like the following:
from abc import ABC
class NameConverter(ABC):
def convert(self,name):
pass
class LatinConverter(NameConverter):
def convert(self, name):
print('Converting using Latin API')
print(f'{name} = "Felis catus"')
return "Felis catus"
class Converter:
def __init__(self, converter: NameConverter):
self.converter = converter
def start(self):
self.converter.convert('Cat')
if __name__ == '__main__':
latin = LatinConverter()
converter = Converter(latin)
converter.start()
The Converter
class now depends on the NameConverter
interface instead of on the LatinConverter
directly. Future updates allow defining name conversions using a different language and API through the NameConverter
interface.
Why is There a Need for SOLID Principles?
SOLID principles help fight against design pattern problems. The overall goal of SOLID principles is to reduce code dependencies, and adding a new feature or changing a part of the code doesn't break the whole build.
As a result of applying SOLID principles to object-oriented design, the code becomes easier to understand, manage, maintain, and change. Since the rules are better suited for large projects, applying the SOLID principles increases the overall development lifecycle speed and efficiency.
Are SOLID Principles Still Relevant?
Although SOLID principles are over 20 years old, they still provide a good foundation for software architecture design. SOLID provides sound design principles applicable to modern programs and environments, not just object-oriented programming.
SOLID principles apply in situations where code is written and modified by people, organized into modules, and contains internal or external elements.
Learn what technical debt is and why coding badly isn't always considered so bad.
Conclusion
SOLID principles help provide a good framework and guide for software architecture design. The examples from this guide show that even a dynamically typed language such as Python benefits from applying the principles to code design.
Next, read about the 9 DevOps principles which will help your team get the most out of DevOps.