Professional Documents
Culture Documents
Python’s growing popularity has resulted in the development of larger and more
intricate projects. This expansion has driven developers to explore high-level
software design patterns, such as those in domain-driven design (DDD).
In this series installment, we will delve into the concepts of Dependency Injection
and its implementation in Python, providing valuable insights for developers
looking to enhance their projects.
All the code discussed in this article is within the accompanying GitHub repository.
The repository provides a convenient way to access and explore the examples in
more detail.
Dependency Injection
Dependency Injection (DI) is a design pattern that encourages loose coupling,
maintainability, and testability within software applications.
DI and DI frameworks have long been popular in statically typed languages like Java
and C#. However, their necessity in dynamic languages like Python has been
debated. Python’s inherent features, such as duck-typing, already offer some
benefits associated with DI.
We will use the example application we built during the previous article about the
Repositories and Unit Of Work patterns.
What exactly is dependency injection?
Dependency Injection (DI) is a design pattern in software development that makes
it easier to manage the relationships between different components or objects in a
program.
In simple terms, it’s a way to provide the necessary dependencies (services, objects,
or values) to a component from an external source rather than having the
component create or find them on its own.
Imagine you’re building a toy car, and instead of having each part (like wheels,
body, and engine) find or create its screws, you provide them from the outside. This
makes it easier to change the screws or reuse the parts in different combinations
without changing the parts themselves.
What are the advantages of dependency injection?
Dependency Injection helps with the following:
class Car:
def __init__(self):
self.engine = Engine()
def start(self):
return self.engine.start()
In this implementation, the Car class creates an Engine object in its constructor,
resulting in tight coupling between the classes. Now, let's explore the three
dependency injection techniques along with examples to improve this code.
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
return self.engine.start()
2. Setter Injection: The setter methods in the class provide the dependencies. In the
example below, the dependency of the Car, the engine is injected via the set_engine
method.
class Car:
def __init__(self):
self.engine = None
class Car:
def start(self, engine):
return engine.start()
Each approach has its advantages and use cases. Constructor and setter injections
are the most commonly used, as they balance flexibility and simplicity.
Open in app
Implementing Dependency Injection in Python
In the previous chapter, we discussed various types of dependency injection that
can be implemented in Python.
In this chapter, we will focus on constructor injection, as it ensures that the object’s
state is correctly initialized. By injecting dependencies through the constructor, we
can be confident that all dependencies are correctly set up.
The figure presented below illustrates the components we will use throughout this
article. We have a single Use Case called CreatePersonAndOrderUseCase that creates an
Order and a Person and stores them in an SQLite database using a single
transaction.
In our project, we utilize a single main file responsible for constructing and
injecting all dependencies into the use case. You can refer to the source code below
for a detailed understanding of the implementation.
@contextmanager
def create_database_connection():
db_connection = sqlite3.connect("./db/data.db")
try:
yield db_connection
finally:
db_connection.close()
When we create a dependency graph based on the provided code, the resulting
visualization appears as follows:
Dependency graph of the main source code: A visualization generated using Graphviz by the author.
Examining the source code, we can see that when initializing the
CreatePersonAndOrderUseCase , we inject an instance of the UnitOfWork class.
Similarly, while creating the UnitOfWork , we inject instances of SQLiteConnection ,
This allows the same construction process to create different object representations
through a step-by-step, chainable interface.
See below for an implementation of the UseCaseBuilder , which employs the Fluent
Builder pattern, a specialized version of the Builder pattern, to build an instance of
the CreatePersonAndOrderUseCase class.
class UseCaseBuilder:
def __init__(self):
self._connection = None
self._person_repository = None
self._order_repository = None
def build(self):
if not self._connection or not self._person_repository or not self._ord
raise ValueError("All dependencies must be provided before building
# Usage
with create_database_connection() as conn:
builder = UseCaseBuilder()
use_case = (
builder.with_connection(SQLiteConnection(conn))
.with_person_repository(SQLitePersonRepository(conn))
.with_order_repository(SQLiteOrderRepository(conn))
.build()
)
In the upcoming chapter, we will explore two distinct Python Dependency Injection
(DI) frameworks and demonstrate their usage. Additionally, we will guide you
through creating a simple, custom DI framework tailored to your needs.
If the requested instance has dependencies, the container will automatically create
and inject them as needed.
Below is our very first simple custom DI container implementation. It features two
key methods: register and resolve . The register method allows you to add types
to the container, while the resolve method creates and returns an instance of a
specified type and its dependencies.
import inspect
class SimpleContainer:
def __init__(self):
self._registry = {}
target_cls = self._registry[cls]
constructor_params = inspect.signature(target_cls.__init__).parameters.
dependencies = [
self.resolve(param.annotation)
for param in constructor_params
if param.annotation is not inspect.Parameter.empty
]
return target_cls(*dependencies)
Finally, the resolve method creates and returns an instance of the requested class
type, with all its dependencies properly injected.
import inspect
class Container:
def __init__(self):
self._registry = {}
self._registry[dependency_type] = implementation
dependencies = [
self.resolve(param.annotation)
for param in constructor_params
if param.annotation is not inspect.Parameter.empty
]
return implementation(*dependencies)
The new part of the register implementation is the for loop using inspect.getmro . It
iterates through the provided implementation class's method resolution order
(MRO). The MRO is the order in which Python looks for a method in the class
hierarchy. This is done using the inspect.getmro() function that returns a tuple of
classes from which the given class is derived.
Then the method checks if the current base class from the MRO is not object and
not the original dependency_type . If both conditions are met, the method registers
the implementation class for the current base class in the _registry . This allows the
container to resolve the dependency for the specific dependency_type and any
intermediate base classes in the class hierarchy.
container = Container()
container.register(BaseConnection, InMemoryConnection)
container.register(BaseRepository[Person], InMemoryPersonRepository)
container.register(BaseRepository[Order], InMemoryOrderRepository)
container.register(UnitOfWork)
container.register(CreatePersonAndOrderUseCase)
create_use_case = container.resolve(CreatePersonAndOrderUseCase)
Dependency Injector
The first DI framework we’ll explore is Dependency Injector. Dependency Injector
is a popular dependency injection framework specifically designed for Python
applications.
Before using Dependency Injector, you must install it using pip install
dependency_injector .
class Container(containers.DeclarativeContainer):
connection = providers.Singleton(
InMemoryConnection
)
person_repository = providers.Singleton(
InMemoryPersonRepository
)
order_repository = providers.Singleton(
InMemoryOrderRepository
)
unit_of_work = providers.Singleton(
UnitOfWork,
connection=connection,
person_repository=person_repository,
order_repository=order_repository
)
create_use_case = providers.Factory(
CreatePersonAndOrderUseCase,
unit_of_work=unit_of_work
)
if __name__ == '__main__':
container = Container()
create_use_case = container.create_use_case()
It’s important to note that this example only scratches the surface of what’s possible
with Dependency Injector.
By exploring these capabilities, you can further enhance and streamline your
dependency management process, enabling more efficient and maintainable code
in your Python projects.
Injector
Injector is another popular Python dependency injection framework. It uses
annotations, type hints, and providers to wire dependencies and manage object
lifetimes.
If you look at the example below, it uses the Injector framework to implement the
same example. Injector uses decorators like @provider and @singleton to register
the types.
class AppModule(Module):
@singleton
@provider
def provide_connection(self) -> InMemoryConnection:
return InMemoryConnection()
@singleton
@provider
def provide_person_repository(self) -> InMemoryPersonRepository:
return InMemoryPersonRepository()
@singleton
@provider
def provide_order_repository(self) -> InMemoryOrderRepository:
return InMemoryOrderRepository()
@inject
@singleton
@provider
def provide_unit_of_work(self,
connection: InMemoryConnection,
person_repository: InMemoryPersonRepository,
order_repository: InMemoryOrderRepository) -> Unit
return UnitOfWork(connection, person_repository, order_repository)
@inject
@singleton
@provider
def provide_create_use_case(self, unit_of_work: UnitOfWork) -> CreatePerson
return CreatePersonAndOrderUseCase(unit_of_work)
injector = Injector(AppModule())
create_use_case = injector.get(CreatePersonAndOrderUseCase)
Like with the Dependency Injector, it’s important to note that this example only
scratches what’s possible with Injector.
Comparing DI Frameworks
All three solutions — Custom Container, Dependency Injector, and Injector can
manage dependencies between components in a project.
Although their approach, features, and complexity differ, the underlying pattern
remains consistent across all of them. In each framework, the process begins with
registering the classes, followed by requesting the instances.
Custom Container
A custom container is a hand-written dependency injection container tailored for
your project. It typically involves writing a container class that manages object
creation, lifecycle, and dependency resolution. Using a custom container gives you
full control over the implementation and lets you decide how to handle specific use
cases. However, it might need more robustness and features of a dedicated
dependency injection library.
Pros:
Cons:
Pros:
Cons:
Injector
Injector is a lightweight and easy-to-use dependency injection library inspired by
Guice (a Java dependency injection library). It focuses on simplicity, which makes it
a good choice for small to medium-sized projects. Injector provides a
straightforward way to handle dependency injection without the complexity of
more feature-rich libraries.
Pros:
Cons:
In summary, a Custom Container is best for small projects where you prefer
simplicity and full control over the implementation. Injector is a good choice for
small to medium-sized projects that need a lightweight and easy-to-use dependency
injection library. Dependency Injector is ideal for larger projects that require a
more feature-rich and powerful dependency injection solution with support for
advanced use cases.
Conclusion
This article is the second piece in a series dedicated to exploring various proven
architectural design patterns in Python aimed at managing complexity.
Each framework has unique strengths and trade-offs, making them suitable for
different project requirements and complexity levels. From simple custom
containers for small projects to more feature-rich libraries like Dependency
Injector for larger projects.
By leveraging the right tools and techniques, developers can create flexible,
testable, and modular code, ultimately leading to better software design and
architecture.
All the code examples discussed in this article are within the accompanying GitHub
repository.
Software Architecture
Follow
Dev & writer exploring open-source innovation & agile. Passionate about learning & teaching.
https://medium.com/@pkalkman/membership