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.
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
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
Formatter depend on concretions, not abstractions.
This means we cannot use another
Formatter or another type of
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
@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
@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.
Now we can inject the
FormatterProtocol into the
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
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
and use it when instantiating the printer
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.
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.