docs/inversion-of-control.md
# Inversion of Control
Imagine the code:
```python
# This is file `dependency.py`
def do_something():
print("I'm doing something")
# This is file `consumer.py`
from dependency import do_something
do_something()
```
The purpose behind using Inversion of Control is reversing the dependency flow in the
code we write. This means we want to `do_something()` without directly importing the
`dependency` module.
## Clean architecture and IoC
Following the principles from clean architecture, `domains` and its subpackages are not
allowed to directly use packages in higher layers and use inversion of control to use
their functionality. E.g.:
`domains.books` requires a repository class to access books in the database. Such
functionality is implemented in `SQLAlchemyAsyncRepository` class from
`sqlalchemy_bind_manager` external package.
`domains.books` contains a `BookRepositoryInterface` protocol that defines the expected
functionality and the whole domain logic is implemented depending on it.
There's no reference to the `SQLAlchemyAsyncRepository` concrete class or
`sqlalchemy_bind_manager` package.
We use [dependency-injector](https://python-dependency-injector.ets-labs.org/) to inject
the concrete class whenever the protocol is expected:
```python
def main():
Container()
service = BookService()
# file `books/_gateway_interfaces.py`
class BookRepositoryInterface(Protocol):
async def save(self, book: BookModel) -> BookModel:
...
# file `domains/books/_service.py`
from domains.books._gateway_interfaces import BookRepositoryInterface
from dependency_injector.wiring import Provide, inject
class BookService:
book_repository: BookRepositoryInterface
@inject
def __init__(
self,
book_repository: BookRepositoryInterface = Provide["book_repository"],
) -> None:
self.book_repository = book_repository
# file `bootstrap/di_container.py`
from sqlalchemy_bind_manager._repository import SQLAlchemyAsyncRepository
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.providers import Factory
class Container(DeclarativeContainer):
# Note that dependency-injector package only allows string references
book_repository: Factory[BookRepositoryInterface] = Factory(
SQLAlchemyAsyncRepository,
# Other parameters needed by the class
)
```
/// note | Dependency graph
```mermaid
flowchart TD
di_decorator["DI decorator"]
main["main()"]
subgraph common.di_injection
Container
end
subgraph domain.books
subgraph service
BookService
end
subgraph _data_access_interfaces
BookRepositoryInterface
end
end
subgraph sqlalchemy_bind_manager
SQLAlchemyAsyncRepository
end
main --> common.di_injection
main --> service
service --> _data_access_interfaces
common.di_injection --> _data_access_interfaces
common.di_injection --> sqlalchemy_bind_manager
sqlalchemy_bind_manager -. "implements" .-> _data_access_interfaces
di_decorator -. "injects dependency" .-> service
di_decorator -. "gets dependency" .-> common.di_injection
```
///
## Alternate approaches to Dependency Injection
Using a Dependency Injection container is the easier solution because it usually takes
care of checking for circular dependencies and invalid setups, and it provides all sort
of helper functions and decorators. It is possible to achieve Inversion of Control
using different techniques (or by combining them together).
### Use directly the DI Container (Service Locator pattern)
Note that you'll want to pass the container in the class constructor and
not import it directly, otherwise you'll end up being dependent on the
concrete classes because nested imported modules, solving nothing.
/// note | Passing the required dependencies in separate parameters, rather than passing the container will make easier to override them in tests
///
```python
# file `domains/books/_service.py`
from dependency_injector.containers import DynamicContainer
from domains.books._gateway_interfaces import BookRepositoryInterface
class BookService:
book_repository: BookRepositoryInterface
def __init__(
self,
container: DynamicContainer,
) -> None:
self.book_repository = container.book_repository()
# entrypoint
from domains.books._service import BookService
from dependency_injector.providers import Factory
from sqlalchemy_bind_manager._repository import SQLAlchemyAsyncRepository
def main():
container = DynamicContainer()
# Note that dependency-injector package only allows string references
container.book_repository = Factory(
SQLAlchemyAsyncRepository,
# Other parameters needed by the class
)
service = BookService(container)
```
/// note | Dependency graph
```mermaid
flowchart TD
main["main()"]
container["DynamicContainer"]
subgraph domain.books
subgraph service
BookService
end
subgraph _data_access_interfaces
BookRepositoryInterface
end
end
subgraph sqlalchemy_bind_manager
SQLAlchemyAsyncRepository
end
main --> container
main --> sqlalchemy_bind_manager
main -. "passes container as parameter" .-> service
sqlalchemy_bind_manager -. "implements" .-> _data_access_interfaces
service --> _data_access_interfaces
service -. "(only for typing)" .-> container
```
///
### Use a factory
A factory helps with keeping in a single place the mapping between
`BookRepositoryInterface` and `SQLAlchemyAsyncRepository` but
**does not** actually implements the Inversion of Control pattern
(see the Dependency graph). We use local imports to:
* Hide the concrete dependencies when importing the factories module.
* Help with avoiding circular imports
/// admonition | Factories orchestration and circular dependencies
type: warning
When you have multiple factories and their concrete classes depends
on each other, it could become difficult to orchestrate them so that
the life cycle of the concrete classes is handled in the correct order
and we don't end up in circular dependencies.
///
```python
# file `bootstrap/factories.py`
from domains.books._gateway_interfaces import BookRepositoryInterface
def book_repository_factory() -> BookRepositoryInterface:
from sqlalchemy_bind_manager._repository import SQLAlchemyAsyncRepository
return SQLAlchemyAsyncRepository()
# file `domains/books/_service.py`
from domains.books._gateway_interfaces import BookRepositoryInterface
from bootstrap.factories import book_repository_factory
class BookService:
book_repository: BookRepositoryInterface
def __init__(self) -> None:
self.book_repository = book_repository_factory()
```
/// note | Dependency graph
```mermaid
flowchart TD
subgraph common.factories
book_repository_factory
end
subgraph domain.books
subgraph service
BookService
end
subgraph _data_access_interfaces
BookRepositoryInterface
end
end
subgraph sqlalchemy_bind_manager
SQLAlchemyAsyncRepository
end
service --> _data_access_interfaces
service -- "Runtime dependency" --> book_repository_factory
common.factories --> _data_access_interfaces
book_repository_factory --> SQLAlchemyAsyncRepository
sqlalchemy_bind_manager -. "implements" .-> _data_access_interfaces
```
///
### Other options
You could write a specific decorator to do dependency injection only for a single parameter.
/// admonition | Additional complexity
type: warning
At this point the task is becoming more and more complex and we retain the runtime
dependency issue of the factory approach, plus introducing complexities like
identifying the parameter name.
We would need to implement the functionalities a dependency injection container already provides.
///
```python
# file `bootstrap/injectors.py` (Theoretical)
def inject_book_repository(f):
@functools.wraps(f)
def wrapper(*args, **kwds):
# This allows overriding the decorator
if "book_repository" not in kwds.keys():
from bootstrap.storage import BookRepository
kwds["book_repository"] = BookRepository()
elif not isinstance(kwds["book_repository"], BookRepositoryInterface):
import warnings
warnings.warn(
f"The specified object ({type(kwds['book_repository'])})"
f" is not an instance of BookRepositoryInterface"
)
return f(*args, **kwds)
return wrapper
```