Skip to content Skip to footer

Do you remember the last time you had a cell phone that charged using a Mini USB cable? Probably not. The Mini USB connector was common over a decade ago. Now it has been replaced by better connectors, from Micro USB (which is also somewhat old-fashioned but still in use in some low-end devices); or USB-C (the most popular connector today).

This means that when we talk about hardware, experience teaches us that each change or update in a module will require the total replacement of the module in question. Due to the nature of the hardware, this is something very difficult to prevent. In software engineering (which is something non-tangible), on the other hand, having to completely change a module whenever we want to improve it is avoidable. It’s something that we developers do every day. Problems arise, however, when there is a lack of organization with some projects, as well as a lack of knowledge of the SOLID principles.

SOLID principles were devised in the early 2000s by Robert C. Martin in his paper Design Principles and Design Patterns. They are guidelines that can be applied to the development of systems that result in source code that is easy to maintain and expand over time. These are:

  • S ingle Responsibility Principle
  • O pen / Closed Principle
  • L iskov’s Substitution Principle
  • I nterface Segregation Principle
  • D ependency Inversion Principle

In the following paragraphs, I will do my best to explain these principles with easy-to-understand language and Python examples.

Single Responsibility Principle (SRP)

A module should be responsible to one, and only one, actor (Martin, 2018).

Imagine that you are a farmer and owner of SOLID Farms going through a bad economic time, so you decide to risk all your available capital to buy apple seeds because you have heard that this is the best-selling fruit in supermarkets. After several years of care, your apple trees finally produce their first fruits. Excited, you take a basket of your best apples to local supermarkets and manage to secure several contracts as a supplier. Your success is so resounding in the first season that you change your company name to SOLID Apples.

In the following season, however, your apple trees decide to grow oranges. Since your supplier contracts are for apples, even your company name, you fear losing everything. But you muster up your courage and revisit the local supermarkets with a basket of your best oranges, apologizing for not being able to supply the promised apples. Some customers accept with skepticism, and others cancel their contracts with you. Yet you still manage to get new contracts to supply oranges to new supermarkets, so you change your company name to SOLID Oranges.

The following season your trees decide to grow bananas… Do you see where this is going? Unless next season your trees decide to grow money, you would be really angry because a tree whose fruits are not determined is a difficult business to manage.

The SRP, then, is this: an apple tree should ALWAYS grow ONLY apples. Each class you create should have a unique and well-determined responsibility. Mixing too many things in one is the secret ingredient of the spaghetti code.

Look at the following code:

class Tree:
    def harvest_apples(self):
        ...

    def harvest_oranges(self):
        ...
   
    def harvest_bananas(self):
        ...
   
    (...)

Solid As A Rock. Object-oriented Design Concepts

For a farm that harvests dozens of different fruits, this class should have at least two methods for each fruit, so you will soon find yourself with thousands of lines of code in a single file. A good way to implement the SRP is to create a class for each fruit.

class AppleTree:
    def harvest(self):
        ...

In this way, each module (tree) is responsible for only one actor (apple).

Open / Closed Principle (OCP)

A module should be open for extension but closed for modification (Martin, 2000).

One morning your supervisor notifies you that there is a new bug in the system. When users try to use a triangle in the service that calculates the sum of the areas of various shapes, an error occurs that stops the application. Diligent, you verify the AreaCalculator service as well as the shapes and you find the following:

from abc import ABC
import math


class Shape(ABC):
    pass

class Rectangle(Shape):
    def __init__(self, length: float, width: float) -> None:
        self.length = length
        self.width = width

class Circle(Shape):
    def __init__(self, radius: float) -> None:
        self.radius = radius

class Triangle(Shape):
    def __init__(self, base: float, heigth: float) -> None:
        self.base = base
        self.heigth = heigth

class AreaCalculator:
    def get_sum_of_areas(self, shapes: list[Shape]) -> float:
        areas = []

        for shape in shapes:
            if isinstance(shape, Rectangle):
                areas.append(shape.length * shape.width)
            elif isinstance(shape, Circle):
                areas.append((shape.radius * shape.radius) * math.pi)
            else:
                raise ValueError("Unknown shape.")
       
        return sum(areas)

area_calculator = AreaCalculator()
rectangle = Rectangle(40, 30)
circle = Circle(32.8)
triangle = Triangle(12, 24)

print(area_calculator.get_sum_of_areas([rectangle, circle, triangle]))

# -- RESULT --
# Exception => ValueError: Unknown shape.

Seeing this you notice that the developer of the triangle shape forgot to modify the AreaCalculator service to be able to calculate its area. Which is understandable, since the developer was a newcomer and didn’t know about AreaCalculator. Since it has happened once, you know that this omission will happen again and you need a solution to prevent it. With this in mind, you change the code to read as follows:

from abc import ABC, abstractproperty
import math


class Shape(ABC):
    @abstractproperty
    def area(self) -> float:
        pass

class Rectangle(Shape):
    def __init__(self, length: float, width: float) -> None:
        self.length = length
        self.width = width

    def area(self) -> float:
        return self.length * self.width

class Circle(Shape):
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        return self.radius * self.radius * math.pi

class Triangle(Shape):
    def __init__(self, base: float, heigth: float) -> None:
        self.base = base
        self.heigth = heigth
   
    def area(self) -> float:
        return self.base * self.heigth * .5

class AreaCalculator:
    def get_sum_of_areas(self, shapes: list[Shape]) -> float:
        return sum([shape.area() for shape in shapes])

area_calculator = AreaCalculator()
rectangle = Rectangle(40, 30)
circle = Circle(32.8)
triangle = Triangle(12, 24)

print(area_calculator.get_sum_of_areas([rectangle, circle, triangle]))

# -- RESULT --
# 4723.851040438043

This new solution not only solves the bug, but will prevent future omissions. By moving the logic that calculates the area to each shape instead of containing all of them in the AreaCalculator, whenever a new shape is added, the developer will know that it must comply with the Shape specification. By doing this, we are “closing” the AreaCalculator class because we no longer need to modify it to extend it with new shapes. All that is required is to simply add a new shape.

Liskov’s Substitution Principle (LSP)

Subclasses should be substitutable for their base classes (Martin, 2000).

Object-oriented programming languages (OOP) have processes called boxing and unboxing, through which a subclass can be implicitly or explicitly replaced by the base class it inherits. For example:

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def fly(self) -> None:
        pass

    @abstractmethod
    def eat(self) -> None:
        pass

class Hawk(Bird):
    def fly(self) -> None:
        print("I...can...see...you...Petter Rabbit...")

    def eat(self) -> None:
        print("Petter Rabbit no more!")

class Pigeon(Bird):
    def fly(self) -> None:
        print("Order 211: Poop and fly! Poop and fly!")

    def eat(self) -> None:
        print("Corn is so yummy!")

def make_birds_fly(birds: list[Bird]) -> None:
    for bird in birds:
        bird.fly()

def make_birds_eat(birds: list[Bird]) -> None:
    for bird in birds:
        bird.eat()

pigeon = Pigeon()
hawk = Hawk()
birds = [pigeon, hawk]

make_birds_fly(birds)
make_birds_eat(birds)

# -- RESULT --
# Order 211: Poop and fly! Poop and fly!
# I...can...see...you...Petter Rabbit...
# Corn is so yummy!
# Petter Rabbit no more!

As you can see, the make_birds_fly and make_birds_eat functions take a list of base objects of type Bird as a parameter, so it can implicitly take objects of type Pigeon as well as Hawk. How about we add a penguin?

(...)

class Penguin(Bird):
    def fly(self) -> None:
        raise NotImplementedError() # Penguins can't fly.

    def eat(self) -> None:
        print("Fish! Fish!")

birds.append(Penguin())

make_birds_fly(birds)
make_birds_eat(birds)

# -- RESULT --
# Order 211: Poop and fly! Poop and fly!
# I...can...see...you...Petter Rabbit...
# Exception => NotImplementedError

Since penguins can’t fly, when it’s their turn in the loop an exception is raised, our program abruptly stops, and neither bird eats. What used to work fine no longer works due to a new member. The latter is what the LSP principle tries to avoid. It states that a subclass must be forced to comply with all the specifications of a base class. So Penguin is bound to implement the fly() method without unexpected errors.

Since penguins can’t fly, when it’s their turn in the loop an exception is raised, our program abruptly stops, and neither bird eats. What used to work fine no longer works due to a new member. The latter is what the LSP principle tries to avoid. It states that a subclass must be forced to comply with all the specifications of a base class. So Penguin is bound to implement the fly() method without unexpected errors.

This new implementation is LSP-compliant because the Penguin class implements the fly() specification without breaking the program. Does it serve our purpose, though? No. It implements the method, of course, but it doesn’t do what it is expected to do. We know that a penguin IS a bird, but it CAN NOT fly, so being forced to implement fly() is foolish. Fortunately, there is another principle similar to LSP but oriented to the base classes: the Interface Segregation Principle.

Interface Segregation​ Principle (ISP)

Many client-specific interfaces are better than one general purpose interface (Martin, 2000).

This principle tells us that interfaces or base classes should not force their subclasses to implement things that they cannot or do not need. This means that the Bird class we saw earlier is not a good representation of a bird, as not all birds can fly, but we were forcing them to. In fact, there are birds that can fly and others that can’t, but there are also birds that can swim! This means that there are subgroups of birds that we were not taking into consideration. A better abstraction of birds and their subgroups that complies with ISP and LSP is the following:

from abc import ABC, abstractmethod

class Bird(ABC):
    @abstractmethod
    def eat(self) -> None:
        pass

class FlyingBird(Bird):
    @abstractmethod
    def fly(self) -> None:
        pass

class SwimmingBird(Bird):
    @abstractmethod
    def swim(self) -> None:
        pass

class Hawk(FlyingBird):
    def fly(self) -> None:
        print("I...can...see...you...Petter Rabbit...")

    def eat(self) -> None:
        print("Petter Rabbit no more!")

class Pigeon(FlyingBird):
    def fly(self) -> None:
        print("Order 211: Poop and fly! Poop and fly!")

    def eat(self) -> None:
        print("Corn is so yummy!")

class Penguin(SwimmingBird):
    def swim(self) -> None:
        print("Kawabonga!")

    def eat(self) -> None:
        print("Fish! Fish!")

def make_birds_fly(birds: list[Bird]) -> None:
    for bird in birds:
        bird.fly()

def make_birds_eat(birds: list[FlyingBird]) -> None:
    for bird in birds:
        bird.eat()

def make_birds_swim(birds: list[SwimmingBird]) -> None:
    for bird in birds:
        bird.swim()

pigeon = Pigeon()
hawk = Hawk()
penguin = Penguin()
flying_birds = [pigeon, hawk]
swimming_birds = [penguin]

make_birds_fly(flying_birds)
make_birds_eat(flying_birds + swimming_birds)
make_birds_swim(swimming_birds)

# -- RESULT --
# Order 211: Poop and fly! Poop and fly!
# I...can...see...you...Petter Rabbit...
# Corn is so yummy!
# Petter Rabbit no more!
# Fish! Fish!
# Kawabonga!

Note that any bird that cannot physically perform an action is no longer required to do so (ISP) and that all our birds meet all required specifications (LSP).

Dependency Inversion​ Principle (DIP)

Depend upon abstractions. Do not depend upon concretions (Martin, 2000).

When an engineer designs the blueprints of a house, each wall is a line that does not come to life until built. While it is a line, it can still be built with wood or cement. Once it has been built, changing from one material to another will require brute force.

Our classes are the blueprints of our system. Our responsibility is to maintain the same unbiased philosophy of the line until the class is built. Otherwise, modifying it would require brute force, as in the following example:

from abc import ABC, abstractmethod


class File:
    pass

class FileService(ABC):
    @abstractmethod
    def save(self, file: File, file_name: str) -> None:
        pass

class PdfFileService(FileService):
    def save(self, file: File, file_name: str) -> None:
        print("Saving as PDF")

class CsvFileService(FileService):
    def save(self, file: File, file_name: str) -> None:
        print("Saving as CSV")

class FileManager:
    def __init__(self, pdf_file_service: PdfFileService) -> None:
        self.file_service = pdf_file_service

    def save(self, file: File) -> None:
        self.file_service.save(file, "")

file = File()
file_manager = FileManager(PdfFileService())
file_manager.save(file)

# -- RESULT --
# Saving as PDF

What will happen when we want to use FileManager to save files in a format other than PDF? We won’t be able to, because FileManager strictly depends on the PdfFileService class (a concrete class) instead of FileService (the abstraction). Whenever we want to use FileManager to save a file in any other extension we will have to violate OCP and modify the class to accept another type or duplicate it to create a new one. Neither of these options is desirable. We need FileManager to be format independent, and we can only achieve that with abstractions.

(...)

class FileManager:
    def __init__(self, file_service: FileService) -> None:
        self.file_service = file_service

    def save(self, file: File) -> None:
        self.file_service.save(file, "")

file = File()
pdf_file_manager = FileManager(PdfFileService())
csv_file_manager = FileManager(CsvFileService())
pdf_file_manager.save(file)
csv_file_manager.save(file)

# -- RESULT --
# Saving as PDF
# Saving as CSV

Now we can save the file in whatever format we need. If we want to save it as a TXT tomorrow, we can create a new TxtFileService class and use it without having to modify FileManager. Our code is more open to change.

In conclusion, when a source code is developed as rigid and solid as a rock, the future hangs by a thread that will always have to be unthreaded. Starting from scratch takes a lot of time and drains project budgets. The SOLID principles help us reduce the risk of this happening by allowing us to leave the door open to change, because change will always come. The acronym SOLID bears the name of what we want to avoid. What we always want to be SOLID AS A ROCK is our commitment to quality, but we never want our source code to be set in stone. Good code is flexible and moldable like water.

Be water, my friend. Empty your mind. Be formless, shapeless, like water. You put water into a cup, it becomes the cup. You put water into a bottle, it becomes the bottle. You put it into a teapot, it becomes the teapot. Now water can flow or it can crash. Be water, my friend. – Bruce Lee

References

Martin, R. C. (2000). Design Principles and Design Patterns. Object Mentor, 1(34), 597. Retrieved from http://staff.cs.utu.fi/~jounsmed/doos_06/material/DesignPrinciplesAndPatterns.pdf

Martin, R. C. (2018). Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall.

Subscribe to our monthly newsletter:

Solid as a rock. Object-oriented design concepts

Get the best blog stories in your inbox!