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 principles are a set of coding principles for object oriented programming.

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.

5 Solid principles explained and listed.

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()).
Single responsibility principle claims that there should never be more than one reason for a class to change.

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.

Open close principle, the second SOLID principle for OOP.

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.

Liskov substitution principle - example of how it works

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.

Interface segregation principle claims that many client-specific interfaces are better than one general-purpose interface.

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 of LatinConverter and its function to convert the provided name. The Converter heavily depends on the LatinConverter 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.

Dependency inversion principle reduces connections between 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.