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)

Comparison benchmark image

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: