SOLID Python part 6: Dependency Inversion Principle

This is the fifth and last article on the SOLID Principles with Python. In this article we will look into the D of SOLID which stands for Dependency Inversion Principle.

  • High level modules should not depend upon low level modules. Both should depend upon abstractions.
  • Abstractions should not depend on details. Details should depend on abstractions.

This article begins with a simple example to explain the Dependency Inversion Principle. After this simple example we will have a look at something you will encounter with more complex applications and how to overcome them.

Simple dependency inversion example

Without Dependency Inversion Principle

Let's assume we have a class that can print books called Printer. Before printing the book, it should be formatted. For this we will use a class called Formatter, which is used by Printer.

class Book:
    def __init__(self, content: str):
        self.content = content

class Formatter:
    def format(self, book: Book) -> str:
        return book.content

class Printer:
    def print(self, book: Book):
        formatter = Formatter()
        formatted_book = formatter.format(book)
        ...
        # Printing the book

This example breaks the DIP because both Printer and Formatter depend on concretions, not abstractions. This means we cannot use another Formatter or another type of Book.

Create classes that use DIP

To fix this we have to create some abstractions and inject them wherever they are needed. To accomplish this we can use Protocols just like we did with the interface segregation principle.

First we will create a Protocol for classes that contain content which will be used to create a Book.

@dataclass
class HasContentProtocol(Protocol):
    content: str

@dataclass
class Book(HasContentProtocol):
    def __init__(self, content):
        self.content = content

Next we create a formatter Protocol and create a concrete formatter. Note the three dots (...) in the format method of FormatterProtocol.

@dataclass
class FormatterProtocol(Protocol):
    def format(self, has_content: HasContentProtocol):
        ...

class A4Formatter(FormatterProtocol):
    def format(self, has_content: HasContentProtocol):
        return has_content.content # This should obviously contain logic to format to A4 size.

Creating the Printer class with the abstractions

Now we can inject the FormatterProtocol into the Printer

class Printer:
    def __init__(self, formatter: FormatterProtocol):
        self.formatter = formatter

    def print(self, has_content: HasContentProtocol):
        formatted_book = self.formatter.format(has_content)
        ...
        # Printing the book

Printing the book

This way we don't have any dependencies on implementations, only on abstractions. So when we want to print a book to A4 we can just use the A4Formatter like this:

book = Book("Amazing book content") # Book is a concretion of HasContentProtocol

formatter = A4Formatter()
printer = Printer(formatter)

printer.print(book)

And when we want to print the book to another format, we just create another concreate FormatterProtocol and use it when instantiating the printer

What about more complex applications?

When you start to build more complex applications the approach I described above would mean you have to pass dependencies all the way down the dependency hierarchy. This is something you really need to avoid since it will lead to maintenance hell.

Dependency injection frameworks

One way to solve this issue is by using dependency injection frameworks. These frameworks will take care of the dependencies you need at runtime anywhere you need them.

There are several dependency injection frameworks for Python that are still under active maintenance. I wrote an article about Python Dependency Injection frameworks which you can use to decide which one is best for your application.