Home / DevOps / SOLID: Object-Oriented Design Principles
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.
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:
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.
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.
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="")' # Database management def save(animal): print(f'Saved 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:
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="")' # A class responsible for database management class AnimalDB: def save(self, animal): print(f'Saved 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.
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="")' class Storage: def save_to_db(self, animal): print(f'Saved 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="")' class Storage: def save_to_db(self, animal): print(f'Saved to the database') def save_to_csv(self,animal): printf(f’Saved 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 to the database') class CSV(): def save(self, animal): print(f'Saved 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'""' class DB(): def save(self, animal): print(f'Saved to the database') class CSV(): def save(self, animal): print(f'Saved 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.
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.
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.
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' = "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:
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' = "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.
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.
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.
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.
Milica Dancuk is a technical writer at phoenixNAP who is passionate about programming. Her background in Electrical Engineering and Computing combined with her teaching experience give her the ability to easily explain complex technical concepts through her content.