FAQ#
Why dependency injection ?#
Dependency injection is a form of inversion of control. In short you’re not creating/retrieving a service by hand, you’re asking for it. Instead of:
from typing import Any
class Database:
def query(self, sql: str) -> Any:
pass
def get_total_count() -> int:
db = Database()
return db.query("SELECT COUNT(*) FROM my_table")
get_total_count()
You do:
def get_total_count(db: Database) -> int:
return db.query("SELECT COUNT(*) FROM my_table")
get_total_count(Database())
Here get_total_count()
doesn’t rely directly on the class Database
anymore. It only expects
to be given an object that exposes the same interface, a method query(sql: str) -> Any
.
This leads to more modular code as there less coupling between get_total_count()
and Database
.
In the later you can change how Database
is instantiated without changing get_total_count()
. It
also leads to easier testing, you only need to provide an object that behaves the same way, no need
to know how Database
actually works.
Now in simple projects, you would just have an instance of Database
in a module and give it to the functions that needs it.
# services.py
db = Database()
# main.py
from services import db
get_total_count(db)
But as your project grows, you’ll have more and more dependencies leading to complex code for the sole purpose of managing them. That’s what Antidote solves for you. You don’t have to manage dependencies, you just need to declare how it should be managed and where it should be injected:
from antidote import injectable, inject
@injectable
class Database:
def query(self, sql: str) -> Any:
pass
@inject
def get_total_count(db: Database = inject.me()) -> int:
return db.query("SELECT COUNT(*) FROM my_table")
get_total_count()
Why use a dependency injection framework ?#
Based on the previous example, let’s suppose you don’t always need the database. Creating the connections takes times, so you want to avoid it if possible. A simple way to do
# injectables.py
from typing import Optional
__db: Optional[Database] = None
def get_db() -> Database:
global __db
if __db is None:
__db = Database()
return __db
That’s still fine to maintain. But how does Database
know where the database is ? This needs configuration:
# config.py
class Config:
host: str = 'localhost'
port: int = 5432
# services.py
__db: Optional[Database] = None
config = Config()
def get_db(host: str, port: int) -> Database:
global __db
if __db is None:
__db = Database(host, port)
return __db
Now it starts to get complicated. How should the config
be handled ? With the above you need to have access
the config
to be able to retrieve the Database
because host
and port
must be specified. So you have a global
object that you carry everywhere. You could use config
inside the get_db()
but that breaks dependency
injection. Is it that bad ? Well, it can quickly become cumbersome in tests, you have to manage a global state used by your
code. Starts to get really ugly, but manageable.
But what if the configuration isn’t coming from a file but it’s stored in the Database / on a remote server ? This starts to get really complex. Now imagine if you have tens of services: templating engine, database, AWS s3 storage, other micro-services with which you communicate, APIs of clients/data sources etc..
Now that you write all your custom code, is it maintainable ? Will a newcomer easily find where a service is coming from / how it’s defined ? Is it easy to override in tests ?
That’s where Antidote shines, it handles all of it for you in a simple, fast, yet maintainable way. So you worry less on how to do all that wiring properly. Here is the same example with Antidote:
from antidote import injectable, inject, const
class Config:
DB_HOST = const('localhost')
DB_PORT = const(5432)
@injectable
class Database:
def __init__(self,
host: str = Config.DB_HOST,
port: int = Config.DB_PORT):
pass
def query(self, sql: str) -> Any:
pass
@inject
def get_total_count(db: Database = inject.me()) -> int:
return db.query("SELECT COUNT(*) FROM my_table")
get_total_count()
Everything is lazily instantiated, only when necessary. You can easily find where the a dependency is coming from and how it’s defined. And you can test any parts of it easily.
Why choose Antidote ?#
Everything is explicit: Some libraries using an
@inject
-like decorator, such as injector, lagom or python_inject will instantiate any missing arguments. Antidote won’t, you have to specify explicitly what must injected.Flexibility: Most libraries will only support services (class), simple factories and singletons. Antidote also provides configuration, interfaces, stateful factories, lazy methods/functions, scopes, async injection.
Maintainability: Most libraries can make it difficult to understand how/where a dependency is created, typically when using a factory to create the dependency. Antidote never hides anything.
Performance: Antidote’s
@inject
is heavily tuned for performance in the compiled version (Cython). (comparison benchmark, antidote benchmark)Testing: Antidote provides testing utilities to fully isolate your tests and are tuned to ensure to be fast even in big projects. (test utilities benchmark)
How does it compare to the most popular dependency injection library, dependency_injector ?
The fundamental difference with dependency_injector is how the container of dependencies is managed. dependency_injector requires a container with all its dependencies to be explicitly created. Afterwards you have to manage the container yourself.
# my_service.py
# Dependency Inject
class MyService:
pass
# services.py
# Dependency Inject
import sys
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
my_service = providers.Singleton(MyService)
# app.py
# Dependency Inject
from dependency_injector.wiring import inject, Provide
from services import Container
from my_service import MyService
@inject
def main(my_service: MyService = Provide[Container.my_service]):
pass
if __name__ == '__main__':
container = Container()
container.wire(modules=[sys.modules[__name__]])
main()
Compared to most libraries, with dependency_injector you’ll always know from where a dependency is coming from. But managing the container yourself has some flaws:
A global object container that you have to manage in your application
The wiring is tied to a specific container instance.
The latter can complicate your tests. dependency_injector recommends using the override mechanism:
with container.my_service.override(mock.Mock()):
f() # <-- overridden dependency is injected automatically
While this works well, it doesn’t fully isolate your tests from each other. All the other services are shared. Full isolation is only do-able by creating a new container re-wiring the whole application. In pytest you would do:
# test.py
import pytest
@pytest.fixture(auto_use=True)
def isolated_container():
container = Container()
container.wire(modules=[sys.modules["app"]])
try:
yield
finally:
container.unwire()
def test_main():
pass
Unfortunately, wire
is extremely slow because it has to check all objects and retrieve
their arguments. Doing this took minutes in a project I worked on, as slow as dropping
and re-creating the whole database for each test. On a very simple case, Antidote provides
full isolation two orders of magnitude faster.
Let’s see how the same example looks with Antidote:
# my_service.py
# Antidote
from antidote import injectable
@injectable
class MyService:
pass
# app.py
# Antidote
from antidote import inject
# from my_service import MyService
@inject
def main(my_service: MyService = inject.me()):
pass
if __name__ == '__main__':
main()
# test.py
import pytest
from antidote import world
@pytest.fixture(auto_use=True)
def isolated_container():
with world.clone(): # creates a new container with the same dependencies
yield
def test_main():
pass
We don’t need to manage a container anymore making the code simpler. Hence Antidote is:
simpler
faster, see comparison benchmark
as maintainable