Skip to content

modern-di is a Python dependency injection framework which supports the following:

  • Automatic dependency graph based on type annotations
  • Also, explicit dependencies are allowed where needed
  • Scopes and context management
  • Python 3.10+ support
  • Fully typed and tested
  • Integrations with aiogram, aiohttp, arq, Celery, FastAPI, FastStream, Flask, gRPC, Litestar, Starlette, taskiq, Typer, and pytest

Reference templates:

For end-to-end patterns drawn from real services, see the Recipes section.


Quickstart

1. Install modern-di

uv add modern-di
pip install modern-di
poetry add modern-di

If you want a framework integration, install the matching adapter — one modern-di-* package per framework (modern-di-fastapi, modern-di-aiohttp, modern-di-litestar, …); see the Integrations section for the full list. For pytest support, install modern-di-pytest.

2. First success

One provider, no scopes, no caching — the smallest honest example. A Group is a namespace that lists your providers; Container.resolve looks a value up by its type.

import dataclasses

from modern_di import Container, Group, providers


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
    database_url: str = "postgresql+asyncpg://localhost/app"


class Dependencies(Group):
    settings = providers.Factory(Settings)


# Call validate() to detect cycles and scope-chain errors up front, at startup
container = Container(groups=[Dependencies])
container.validate()
settings = container.resolve(Settings)
print(settings.database_url)

Without cache=, Factory calls the creator on every resolve — fine for cheap, stateless objects, but not what you want for a database engine you only want to build once.

3. Create once, reuse

Add cache=True (via CacheSettings, which also lets you attach a finalizer) to turn settings into a singleton, and switch to the with form so the finalizer runs when the container closes.

import dataclasses

from modern_di import Container, Group, providers


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
    database_url: str = "postgresql+asyncpg://localhost/app"


def close_settings(settings: Settings) -> None:
    print(f"closing settings ({id(settings)})")


class Dependencies(Group):
    settings = providers.Factory(
        Settings,
        cache=providers.CacheSettings(finalizer=close_settings),
    )


with Container(groups=[Dependencies]) as container:
    first = container.resolve(Settings)
    second = container.resolve(Settings)
    print(id(first), id(second), first is second)  # same instance, cached on first resolve
# `close_settings` ran here, on `with` exit

4. Request scope

Real apps also need state that lives for one request: a UserRepository rebuilt per request, fed by a RequestId supplied at request time via ContextProvider. Build a Scope.REQUEST child container with build_child_container(scope=..., context={...}); it can still resolve the APP-scoped Settings through the parent.

import dataclasses

from modern_di import Container, Group, Scope, providers


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
    database_url: str = "postgresql+asyncpg://localhost/app"


def close_settings(settings: Settings) -> None:
    print(f"closing settings ({id(settings)})")


@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class RequestId:
    value: str


@dataclasses.dataclass(kw_only=True, slots=True)
class UserRepository:
    settings: Settings       # auto-injected by type, resolved through the request container
    request_id: RequestId    # supplied via context, one value per request

    def find(self, user_id: int) -> dict[str, object]:
        return {"id": user_id, "request_id": self.request_id.value}


class Dependencies(Group):
    settings = providers.Factory(
        Settings,
        cache=providers.CacheSettings(finalizer=close_settings),
    )
    request_id = providers.ContextProvider(RequestId, scope=Scope.REQUEST)
    user_repository = providers.Factory(UserRepository, scope=Scope.REQUEST)


with Container(groups=[Dependencies]) as container:
    request_context = {RequestId: RequestId(value="req-1")}
    with container.build_child_container(scope=Scope.REQUEST, context=request_context) as request:
        repo = request.resolve(UserRepository)
        user = repo.find(42)
        print(user)
    # REQUEST-scope finalizers ran here (none declared in this example)
# APP-scope finalizers ran here (closes settings)

A framework integration (linked under "Where to next" below) builds and tears down this REQUEST child container for you automatically. Resolution itself is always synchronous; use async with (on both the container and the child) instead of with only when a provider registers an async finalizer — see Lifecycle.

Where to next