1. Dependency Injection container basics¶
A dependency injection container associates contracts (interfaces, protocols) with their concrete implementations. Interactions with the container can be divided into two categories: registration and resolution. While registration usually precedes resolution, it’s necessary to understand what happends during resolution before beginning to discuss the registration stage.
1.1. Resolution¶
The ultimate goal of the container is to provide instances of objects that satisfy the requested contract. We will call such instances beans, and the process of obtaining such instances from a container — resolution. In some places, we will highlight that we are talking about the bean and not its type by calling it a bean instance instead.
During resolution, the container attempts to look up registrations for the requested contract, and construct or retrieve an appropriate bean. Meanwhile, the code that initiated (requested) the bean resolution needs not know how the bean was constructed, or even how the contract will be fulfilled. This ensures loose coupling and makes your assumptions about the requested objects more explicit.
Let’s take a look at an example, without going into much detail concerning how the container is configured:
from abc import ABC, abstractmethod
from grundzeug.container import IContainer, Container
from grundzeug.container.registrations import RegistrationTypes
class Contract(ABC):
@abstractmethod
def foo():
raise NotImplementedError()
class FirstImplementation(Contract):
def foo():
...
...
container = Container()
# Configure the container to resolve an instance
# of `FirstImplementation` when `Contract` is requested.
configure_container(container)
# We do not care how the contract is fulfiled,
# but `bean` will be an instance of `FirstImplementation`
# in this case.
bean = container.resolve[Contract]()
bean.foo()
1.1.1. Container hierarchies¶
Grundzeug, being a hierarchical container, supports overriding registrations in child containers. We can demonstrate the hierarchical nature of the container by extending our current example:
class SecondImplementation(Contract):
def foo():
...
...
child_container = Container(parent=container)
# Child containers inherit registrations.
bean_from_child = child_container.resolve[Contract]()
assert isinstance(bean_from_child, FirstImplementation)
# Configure the child container to resolve an instance
# of `SecondImplementation` when `Contract` is requested.
configure_child_container(child_container)
# The bean resolved from the child container will be an
# instance of `SecondImplementation`, while the bean
# resolved from the root container will still be an
# instance of FirstImplementation.
bean = container.resolve[Contract]()
bean_from_child = child_container.resolve[Contract]()
assert isinstance(bean, FirstImplementation)
assert isinstance(bean_from_child, SecondImplementation)
1.1.2. Basic dependency injection¶
So far, we’ve been treating our container as a service locator. While calling resolve
is sometimes necessary, it should be a rare occurance in a high-quality code-base. Instead, we should aim to automatically inject dependencies into places where they are required.
Let’s take a look at a contrived example:
from grundzeug.container.di import Inject
def perform_foo(greeting: str, contract_impl: Inject[Contract]) -> None:
contract_impl.foo()
print(greeting)
# Partially apply perform_foo using beans from the
# container, binding a bean that satisfies
# Contract to contract_impl.
injected_func : Callable[[str], None] = container.inject(perform_foo)
# We can still pass arguments to an injected function!
injected_func("hello world")
Note
Inject[Contract]
is just a shorthand for PEP 593 Annotated[Contract, InjectAnnotation[Contract]]
.
By calling container.inject
on perform_foo
, we have essentially performed injected_func = functools.partial(perform_foo, contract_impl=container.resolve[Contract]())
.
1.2. Registration¶
Let’s set aside resolution for now and talk about the registration stage, which defines where the beans come from, where they are stored, and when they should be discarded.
Each container has a list of registrations associated with it. Each registration is responsible for constructing and keeping track of bean instances associated with a specified contract. Different registration types implement different bean lifecycle management strategies, giving us the ability to, for instance, create a new bean each time the contract is requested.
The process of adding a new registration to a container is called bean registration.
1.2.1. Providing bean instances to registrations¶
1.2.1.1. Instance registration¶
Instance registrations are a special (trivial) case, since the user must provide the actual bean that will be resolved each time the associated contract is requested from the container (or one of its descendants):
def configure_container(container: IContainer):
bean = FirstImplementation()
container.register_instance[Contract](bean)
assert id(container.resolve[Contract]()) == id(bean)
While sometimes useful, instance registration “misses the point” of having a dependency injection container, because it requires us to explicitly construct the bean, passing its dependencies manually.
1.2.1.2. Type registration¶
The most common way to register a bean definition is to provide the bean’s type. When a new instance of the bean is required, Grundzeug will simply call the type’s constructor, injecting any dependencies in the process.
To demonstrate this, let’s create a third implementation of Contract
, which depends on some dependency that satisfies DependencyContract
:
class ThirdImplementation(Contract):
def __init__(dependency: Inject[DependencyContract]):
...
def foo():
...
def configure_container(container: IContainer):
# Configure an implementation for DependencyContract.
add_dependency_impl_to_container(container)
...
container.register_type[Contract, ThirdImplementation]()
If we configure the container as specified above, the container will create an instance of ThirdImplementation
the first time Contract
is requested, injecting its dependencies, and keep returning the same instance each time the contract is requested. Moreover, every child container will return the same instance unless a superseding bean registration is provided.
Sometimes, the contract matches the implementation, in which case you may use the shorthand syntax:
# Register FirstImplementation to satisfy FirstImplementation
container.register_type[FirstImplementation]()
1.2.1.3. Factory registration¶
Factory injection behaves similarly to type registration, except that you provide a factory function instead of a type:
def configure_container(container: IContainer):
def create_first_implementation():
return FirstImplementation()
container.register_factory[Contract](create_first_implementation)
When the factory is called, it also receives any required dependencies. The code used to construct the bean looks roughly like this:
return container.inject(factory)()
1.2.2. Registration types¶
1.2.2.1. Container¶
When registering a type or a factory, the default behaviour is to create the bean on first request, and then return the same instance for any subsequent requests. This registration type is great for replacing unnecessary singletons:
# The following two calls to register_type are equivalent:
container.register_type[Contract, FirstImplementation]()
container.register_type[Contract, FirstImplementation](
registration_type=RegistrationTypes.Container
)
assert id(container.resolve[Contract]()) == id(container.resolve[Contract]())
assert id(container.resolve[Contract]()) == id(child_container.resolve[Contract]())
1.2.2.2. Hierarchical¶
Sometimes, it is desirable for descendant containers to have their own bean instances without any additional configuration. The Hierarchical
registration type maintains separate bean instances for each descendant container, instantiating them only after they have been requested.
container.register_type[Contract, FirstImplementation](
registration_type=RegistrationTypes.Hierarchical
)
assert id(container.resolve[Contract]()) == id(container.resolve[Contract]())
assert id(container.resolve[Contract]()) != id(child_container.resolve[Contract]())
1.2.2.3. Transient¶
In some cases, the bean should be instantiated on each request. The Transient
registration type provides just that: it calls the configured factory on each resolution, each time giving us a new bean instance (assuming that the factory returns a new bean instance on each call).
container.register_type[FirstImplementation](
registration_type=RegistrationTypes.Transient
)
assert id(container.resolve[Contract]()) != id(container.resolve[Contract]())
1.2.3. Named beans¶
In addition to contracts, there is a secondary mechanism for identifying beans in Grundzeug. You may register multiple beans to contracts under different names:
def configure_container(container: IContainer):
first_bean = FirstImplementation()
second_bean = FirstImplementation()
container.register_instance[Contract](first_bean, bean_name="first_bean")
container.register_instance[Contract](second_bean, bean_name="second_bean")
assert id(container.resolve[Contract](bean_name="first_bean")) == id(first_bean)
assert id(container.resolve[Contract](bean_name="second_bean")) == id(second_bean)