Getting started with Rodi¶
This page introduces the basics of using Rodi, including:
- An overview of dependency injection.
- The use cases Rodi is intended for.
Overview of dependency injection¶
Consider the following example:
The type B
depends upon the type A
, because it requires an instance of A
in its constructor. In other words, A
is a dependency of B
.
For a more concrete example, consider the following:
class ProductsRepository:
"""Provides methods to read, write, and delete products information."""
class ProductsService:
"""Provides business logic for managing products."""
def __init__(self, repository: ProductsRepository):
self.repository = repository
The ProductsService
requires an instance of ProductsRepository
. The former
handles business logic, while the latter defines a type responsible for
storing, reading, and deleting product information.
Imagine we also need to send emails when certain events happen, the
ProductsService
would likely have an additional dependency:
class ProductsService:
"""Provides business logic for managing products."""
def __init__(
self,
repository: ProductsRepository,
email_handler: EmailHandler
):
self.repository = repository
self.email_handler = email_handler
Encapsulating the code that performs data access operations (ProductsRepository
)
and that sends emails (EmailHandler
) into dedicated classes is the right
approach, as the same functionality can be reused in other services (e.g.,
OrdersService, AccountsService) without duplicating code.
Dependencies could also be instantiated by the classes that need them:
class ProductsService:
"""Provides business logic for managing products."""
def __init__(self):
self.repository = ProductsRepository()
self.email_handler = EmailHandler()
However, this approach has several limitations.
- Scalability Issues: As the application grows, managing dependencies manually within classes becomes cumbersome. It can lead to duplicated code and make the system harder to maintain.
- Tight Coupling: The
ProductsService
class is tightly coupled to concrete implementations of its dependencies. This makes it less convenient to replaceProductsRepository
andEmailHandler
with different implementations (e.g., a mock for testing or a different database backend). - Reduced Testability: Since dependencies are instantiated within the
class, it is necessary to modify the properties of instances of
ProductsService
, to replace them with mocks or stubs during unit testing. - Lack of Flexibility: If the application needs to use a different
implementation of
ProductsRepository
(e.g., for different environments or configurations), the source code of theProductsService
class must be modified. - Code Duplication: If multiple classes need the same dependency, each class would need to instantiate it, leading to duplicated code and increased maintenance overhead.
- Configuration Management: Managing configuration settings (e.g., database connection strings or API keys) becomes harder because they are scattered across multiple classes instead of being centralized.
- Runtime Flexibility: Instantiating dependencies directly in the class makes it harder to dynamically change or configure dependencies at runtime (e.g., switching to a different implementation based on environment variables).
Alternatively, dependencies could be instantiated at the module level and managed as global variables. Instantiating dependencies as globals at module level is generally not ideal, as it leads to:
- Tight Coupling to Global State: When dependencies are global, any part of the application can access and modify them. This makes the code tightly coupled to the global state, leading to unpredictable behavior and bugs that are hard to trace.
- Reduced Testability: Global dependencies make unit testing difficult because tests cannot easily isolate or mock dependencies. Each test might inadvertently affect or be affected by the global state, leading to flaky tests.
Dependency injection can help addressing the problems listed above.
Inversion of Control¶
Inversion of Control (IoC) is a design principle in which the control of object creation and dependency management is inverted from the class itself to an external entity, such as a framework or container. Instead of a class instantiating its dependencies directly, they are provided to the class from the outside. This promotes loose coupling and enhances testability. Dependency Injection is a common implementation of IoC.
Dependency Injection¶
Dependency Injection is a design principle where a class does not create its own dependencies. Instead, the dependencies are provided (or "injected") into the class from the outside. This makes the class more flexible, easier to test, and less dependent on specific implementations.
If we consider again the classes A
and B
described earlier, they can be
registered and resolved using Rodi this way:
# main.py
from example1 import A, B
from rodi import Container
container = Container()
# register types:
container.add_transient(A)
container.add_transient(B)
# resolve B
example = container.resolve(B)
# the container automatically resolves dependencies
assert isinstance(example, B)
assert isinstance(example.dependency, A)
Completely non-intrusive.
Notice that Rodi is completely non-intrusive and does not require any changes to the source code of the types it handles. This was one of the library's primary design goals.
In this example, both A
and B
are concrete types. Rodi can resolve concrete
types without any issues. However, the true power of dependency injection
becomes evident when we use abstract types or interfaces to define
dependencies. Let's talk about the Dependency Inversion Principle.
Dependency Inversion Principle¶
The Dependency Inversion Principle (DIP) is a design principle that says high-level modules (like business logic) should not depend on low-level modules (like database access). Instead, both should depend on abstractions, like interfaces or abstract classes. This makes the code more flexible and easier to change because you can swap out the low-level details without affecting the high-level logic. Inversion of Control aligns with the Dependency Inversion Principle.
Consider the following example, of ProductsService
, ProductsRepository
,
and SQLProductsRepository
.
Explanation:
- The abstraction
ProductsRepository
defines the interface for data access operations. - The high-level class (
ProductsService
) depends on this abstraction, not on concrete implementations. - The high-level class (
ProductsService
) implements business logic and depends on theProductsRepository
abstraction. ProductsService
does not depend on the details of how data is stored or retrieved, and it is not concerned with those details.- The low-level class (
SQLProductsRepository
) implements theProductsRepository
interface using an SQL database. - It can be swapped out for another implementation (e.g.,
InMemoryProductsRepository
) without modifying theProductsService
.
classDiagram
class ProductsRepository {
<<interface>>
+get_all_products() list~Product~
+get_product_by_id(product_id: int) Product | None
+create_product(product: Product) int
}
class SQLProductsRepository {
+get_all_products() list~Product~
+get_product_by_id(product_id: int) Product | None
+create_product(product: Product) int
}
class ProductsService {
-repository: ProductsRepository
+get_all_products() list~Product~
+get_product_by_id(product_id: int) Product | None
+create_product(product: Product) int
}
ProductsRepository <-- SQLProductsRepository : implements
ProductsService --> ProductsRepository : depends on
The benefits of DIP are:
- Loose Coupling: The
ProductsService
is decoupled from the specific implementation of the repository. - Flexibility: You can easily replace
SQLProductsRepository
with another implementation (e.g., a mock for testing). - Testability: The
ProductsService
can be tested independently by injecting a mock or stub implementation ofProductsRepository
.
To better understand the concept, consider the following example that shows how those classes can be imported and instantiated:
As the number of dependencies grow, the code that instantiates objects can easily become hard to maintain. To simplify the management of dependencies and reduce the complexity of object instantiation, we can leverage a dependency injection framework like Rodi.
The Repository pattern example¶
The three classes described above: ProductsService
, ProductsRepository
, and
SQLProductsRepository
, can be wired using Rodi this way:
Some interesting things are happening in this code:
- At line 9, an instance of
rodi.Container
is created. This class is used to register the types that must be resolved, and to resolve those types. - It was not necessary to modify the source code of the classes being handled: Rodi inspects the code of registered types to know how to resolve them.
- A factory function is used to define how the instance of
sqlite3.Connection
is to be created. This is convenient because theconnect
method, which returns an instance of that class, requires astr
, and resolving base types withDI
is not a good idea. - The factory has a return type annotation: Rodi uses that type annotation as the key type that is resolved using the factory function. Note that a factory might declare a more abstract type than the one it returns (following the DIP principle).
- Since the constructor of the
SQLProductsRepository
class does not include a type annotation for itsdb_connection
dependency, an alias is configured at line 29 to instruct the container to resolve parameters nameddb_connection
as instances ofsqlite3.Connection
. Alternatively, we could have updated the source code ofSQLProductsRepository
to include a type annotation in its constructor. - At line 31, the abstract type
ProductsRepository
is registered, instructing the container to resolve that type with the concrete implementationSQLProductsRepository
. According to the DIP principle, when registering an abstract type and its implementation, Rodi requires using the abstract type as key. - At line 32, the
ProductsService
type is also registered, because this is required to build the graph of dependencies. - At line 36, an instance of
ProductsService
is obtained through DI. Since this is the first time theContainer
needs to resolve a type, it runs code inspections to build the tree of dependencies. These code inspections are executed only once, unless new types are registered in the sameContainer
. The container obtains all necessary objects: from thedb_connection
and theSQLProductsRepository
to resolve the abstract dependencyProductsRepository
, used to instantiate the requestedProductsService
.
Rodi's use cases¶
Rodi is designed to simplify objects instantiation and dependency management. It can
inspect constructors (__init__
methods) and class properties to automatically resolve
dependencies.
Support for inspecting class properties is intended to reduce code verbosity. Note how in the example below, it is necessary to write three times 'dependency':
The same classes can be written this way:
Rodi would automatically instantiate B
and populate its dependency
property
with an instance of A
.
graph TD
A[Rodi] --> B[Resolves __init__ methods]
A --> C[Resolves class properties]
Container lifetime¶
The primary use case of Rodi is to instantiate a single Container
object, configure it
with all required dependencies at application startup, and maintain it in an immutable
state throughout the application's lifetime. It is anyway possible to work with multiple
containers, and to modify them even after the dependency graph has been built. Modifying
a Container
after the dependency graph has been built is an anti-pattern and can lead
to unexpected behaviour. More details on this subject are provided in the next page.
Sync vs Async¶
Rodi is designed for synchronous code. It intentionally does not provide an asynchronous code API because object constructors should be lightweight and run synchronously. Supporting asynchronous type resolution would introduce performance overhead due to the complexity of asynchronous operations, and the extra machinery they require.
Constructors (__init__
methods) are typically designed to be lightweight and avoid
CPU intensive blocking operations or performing I/O operations.
Type annotations¶
Rodi can use both type annotations and naming conventions to build graphs of dependencies.
Type annotations is the recommended way to keep the code clean and explicit.
Automatic aliases¶
Rodi supports automatic aliases. When a type is registered, the container creates a set of aliases based on the class name. Consider the following example:
Aliases are only used when type annotations are missing. They serve solely as a fallback and always refer to a type that can be resolved.
This design decision is based on the assumption that classes usually have names that are distinct enough to be unambiguously identified, even across namespaces.
In the example above, the following set of aliases is created for the registered types:
{
'CatsRepository': {<class '__main__.CatsRepository'>},
'catsrepository': {<class '__main__.CatsRepository'>},
'cats_repository': {<class '__main__.CatsRepository'>},
'B': {<class '__main__.B'>},
'b': {<class '__main__.B'>}
}
Disabling automatic aliases.
Some programmers might dislike the automatic aliasing feature, as it can lead to
unexpected behavior if naming conventions are not followed consistently. To disable this
feature, set the strict
parameter to True
when creating the container:
Summary¶
This page covered the ABCs of Dependency Injection and Rodi. The general concepts presented here apply to others DI frameworks as well.
The next page will start diving into Rodi's details, starting with explaining how to register types.
Last modified on: 2025-04-17 07:04:37