SOLID Principles – If you are a professional developer, there’s no way around these rules for object-oriented software design.

What does SOLID stand for?

SOLID is a mnemonic acronym of 5 acronyms themselves: SRP - Single Responsibility Principle, OCP - Open-Closed Principle, LSP - Liskov Substitution Principle, ** ISP** - Interface Segregation Principle, DIP - Dependency Inversion Principle.

These ideas were first mentioned by Robert C. Martin (Uncle Bob) in his paper "Design Principles and Design Patterns". Later, Michael C. Feathers coined the SOLID acronym, which was then re-used by Uncle Bob in the chapter "Design Principles" in his book "Clean Architecture".

Single Responsibility Principle (SRP)

There should never be more than one reason for a class to change

SRP: The Single Responsibility Principle - Robert C. Martin

The single responsibility principle was coined by Robert C. Martin himself but he also pays credit to Tom DeMarco's concept of cohesion, which he described in "Structured Analysis and System Specification". It is the first of the SOLID principles and encourages to give classes only a single and definite reason to change. If you can think of more than a single reason for the class to change, the class has more than one responsibility. SRP is often confused with the "Do one thing"-rule At a first glance, this concept seems rather abstract and useless, but the example that is provided in the initial paper is kind of helpful:

How to violate the Single Responsibility Principle. The Rectangle class has two reasons to change.

Imagine a rectangle class that has the two public methods draw and area. The draw method should return coordinates or any graphical representation. The area method returns the area of the current rectangle instance. The rectangle class thereby has two responsibilities. It's responsible for calculating the area and responsible to draw itself.

class Rectangle(object):
def __init__(self, height: float, width: float):
self.height = height
self.width = width
def draw(self) -> VisualRepresentation:
return visual_representation(self)
def area()-> float:
return self.height*self.width

If you are confused by the types behind the arguments in the constructor, you should have a look at Python type hints. However, changes in the rectangle class would affect the GUI as well as the geometric calculation app. Changes in the rectangle class could potentially break the whole system or lead to unpredictable bugs. The solution Martin proposes in this specific case is to split the class into two separate ones:

Proposed solution to comply with the Single Responsibility Principle. A second class GeometricRectangle, implementing the math related methods

This separation leads to a single responsibility for each the geometric rectangle and the rectangle class. Changes in the draw method now can no longer affect the way they are calculated.

class GeometricRectangle(object):
def __init__(self, height: float, width: float):
self.height = height
self.width = width
def area()-> float:
return height*width
class Rectangle(GeometricRectangle):
def draw(self) -> VisualRepresentation:
return visual_representation(self)

The attentive readers might have noticed that this concept's strict and mindless usage will also lead to poor software design. It would help if you never used the single responsibility theory without proper support of other constructs like modules or facades. Otherwise, the structure proposed by the SRP will cause the code to break into a thousand confusing pieces

Open-Closed Principle (OCP)

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

The Open Closed Principle - Robert C. Martin

Whatever your personal definition of stable code is, this SOLID principle should be part of it. A good software architecture will almost certainly always fulfill the open-closed rule. Originally, Bertrand Mayer coined this concept in his book "Object-Oriented Software Construction". OCP applies to all kinds of objects in programming you can imagine. This philosophy's main focus is to allow you to scale your classes and modules without caring about legacy code. To simplify this, we are just going to look at applying it to classes.

The key message of the open-closed principle, in this case, is that your classes should be open for extension but closed for modification. Meaning, once you created your class, it shouldn't change any more. However, it could change by simply creating a child class that thereby extends its behavior. Imagine you had a user class that holds a name and an age.

class User:
def __init__(self, username: str, age: int):
self.username = username
self.age = age
def __repr__(self):
return f"User: {self.username}, {self.age} years old"

Now imagine you want to extend this class by an attribute that saves the user's favorite game. A naive solution to this problem would be to simply add an attribute "favorite_game" to the user class:

class User:
def __init__(self, username: str, age: int, favorite_game: Game):
self.username = username
self.age = age
self.favorite_game = favorite_game
def __repr__(self):
return f"User: {self.username}, {self.age} years old, favorite game: {self.favorite_game}"

This might work if your system is small or in development. But if you want to change this in a productive system, things are going to break. Not only did the signature change because the constructor now expects a favorite_game, but also the __repr__ method changed and might break things further. This violates Meyer's postulate. A possible solution to this again could be inheritance:

class User:
def __init__(self, username: str, age: int):
self.username = username
self.age = age
def __repr__(self):
return f"User: {self.username}, {self.age} years old"
class Gamer(User):
def __init__(self, username: str, age: int, favorite_game: Game):
super().__init__(username, age)
self.favorite_game = favorite_game
def __repr__(self):
return f"User: {self.username}, {self.age} years old, favorite game: {self.favorite_game}"

By using this, your functionality is extended, but you don't apply any changes to the original class. Please note: Inheritance isn't always a good solution or a solution at all, but it is the easiest example to make. SOLID is not directly related to inheritance or polymorphism.

Liskov Substitution Principle (LSP)

Subtype Requirement: Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T.

A behavioral notion of subtyping - Barbara Liskov and Jeanette Wing

When you are most familiar with Python or hate maths, the Liskov substitution principle might be slightly confusing. This is because it originally was designed for statically typed programming languages like Java, C, or FORTRAN. Barbara Liskov introduced it in 1987. It is often considered the hardest of the SOLID principles to understand. Visualizing Liskov's definition leads to the following class diagram:

Liskov Substistion Principle definition as an UML class diagram

We have a type T and a subtype S, and objects of x that are of type T and objects y of type S. Also, all the elements posses an attribute φ. A representation in Python code could look like this:

class T:
def __init__(self, phi: list):
self.phi = phi
class S(T):
pass
if __name__ == "__main__":
x = T(phi=["a", "b"])
y = S(phi=["c", "d"])

And this indeed fulfills the Liskov substitution principle. Any object of type S could replace its parent class. How could you possibly violate this?

class T:
def __init__(self, phi: list):
self.phi = phi
class S(T):
def __init__(self, phi: str):
self.phi = phi
if __name__ == "__main__":
x = T(phi=["a", "b"])
y = S(phi="c, d")

If you look closely, you will see that the subtype S implements its own class attribute phi of type string instead of list. This violates Liskov's theory. You can no longer replace T with objects of type S. One becomes aware of this concept's meaningfulness when you think about implementing a method "print_phis" that should solely print all elements in the class attribute phi. Either instances of class T or S would run into a runtime error or lead to a higher degree of complexity in the "print_phis" method due to additional conditionals.

Interface Segregation Principle (ISP)

Clients should not be forced to depend upon interfaces that they do not use.

The Interface Segregation Principle - Robert C. Martin

ISP or interface segregation principle is the SOLID equivalent of high cohesion in GRASP (General Responsibility Assignment Software Principles). Thereby it naturally supports loose coupling and maintainability. The main message behind ISP is that large Interfaces should be split into multiple ones. Functions and classes should not depend on methods they don't use. Look at the following example for a "fat" interface:

class GeometricInterface(ABC):
@abstractmethod
def get_area() -> float:
raise NotImplementedError
@abstractmethod
def get_diameter() -> float:
raise NotImplementedError
class Square(GeometricInterface):
def __init__(self, height: float, width: float):
self.height = height
self.width = width
def get_area():
return self.height*self.width
def get_diameter() -> float:
raise NotImplementedError
class Circle(GeometricInterface):
def __init__(self, radius: float):
self.radius = radius
def get_area():
return self.radius * PI **2
def get_diameter() -> float:
return self.radius*2

Please excuse the far-fetched example, nothing I'm proud of. However, as you can see, the GeometricInterface has two abstract methods of which get_diameter() is not used by its subtype square. Thereby the interface segregation rule is violated. There are many possible solutions to this. We could, for instance, segregate the GeometricInterface into three Interfaces:

class GeometricInterface(ABC):
@abstractmethod
def get_area() -> float:
raise NotImplementedError
class ElipseInterface(GeometricInterface, ABC):
@abstractmethod
def get_diameter() -> float:
raise NotImplementedError
class RectangleInterface(GeometricInterface, ABC):
pass
class Square(Rectangle):
def __init__(self, height: float, width: float):
self.height = height
self.width = width
def get_area(self):
return self.height * self.width
class Circle(Elipse):
def __init__(self, radius: float):
self.radius = radius
def get_area(self):
return self.radius * PI ** 2
def get_diameter(self) -> float:
return self.radius * 2

Dependency Inversion Principle (DIP)

A: High level modules should not depend upon low level Modules. Both should depend upon abstractions.

B: Abstractions should not depend upon details. Details should depend upon abstractions.

The Dependency Inversion Principle - Robert C. Martin

In my opinion, the dependency inversion principle is the easiest one to understand. However, it can be the hardest to implement. In essence, the DIP states that modules should not rely on modules that belong to a subordinate concept and should not rely on generalizations. A quick piece of code that is often referenced:

class LightBulb:
def __init__(self, initial_state: bool=False):
self.power = initial_state
def turn_on(self):
self.power = True
def turn_off(self):
self.power = False
class Switch:
def __init__(self, light_bulb: LightBulb, pressed: bool=False):
self.light_bulb = light_bulb
self.pressed = pressed
def toggle(self):
self.pressed = not self.pressed # Toggle
if self.pressed:
self.light_bulb.turn_on()
else:
self.light_bulb.turn_off()

The DIP violation here is that a switch is a concept that is logically in a layer above the light bulb, and the switch relies on it. This will lead to poor extensibility or even circular imports that prevent the program from being interpreted or compiled.

Violating the Dependency Inversion Principle. The controller module relies on the device module instead vice versa

Instead of the light bulb telling the switch how the bulb should be handled, the switch should tell the light bulb how to implement it. The naive approach would be to define an interface that tells the light bulb how it should behave to be used with a switch.

class Device(ABC):
power: boolean
def __init__(self, initial_state: bool=False):
self.power = initial_state
def turn_on(self):
raise NotImplementedError
def turn_off(self):
raise NotImplementedError
class Switch:
def __init__(self, device: Device, pressed: bool=False):
self.device = device
self.pressed = pressed
def toggle(self):
self.pressed = not self.pressed # Toggle
if self.pressed:
self.device.turn_on()
else:
self.device.turn_off()
class LightBulb(Device):
def turn_on(self):
self.power = True
def turn_off(self):
self.power = False

Visualized as a class diagram, this source code would lead to the object-oriented design:

Dependency Inversion Principle in an UML class diagram. A Device Interface as the solution for the switch not to rely on the light bulb

The dependency has been inverted. Instead of the switch relying on the light bulb, the light bulb now relies on an interface in a higher module. Also, both rely on abstractions, as required by the DIP. Last but not least, we also fulfilled the requirement "Abstractions should not depend upon details. Details should depend upon abstractions" - The details of how the device behaves rely on the abstraction (Device interface).

Conclusion

As with all guidelines, it's not recommended to follow the SOLID principles blindly either. Principles, patterns, and guidelines can easily turn into anti-patterns and lead to new problems. Sometimes it not preventable to violation some of these. Our job as a developer is to create code and programs that are easy to read and thereby easy to maintain and extend. Robustness and correctness are more or less nice side effects of this approach. These are great guidelines, but that's what they are, guidelines. Specific problems need specific solutions. All principles and patterns can offer are suggestions and best practices.

If you support my statement or are of a completely different opinion I would love to hear about it in the comments section.

References