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, andpytest
Reference templates:
- Litestar — litestar-sqlalchemy-template
- FastAPI — fastapi-sqlalchemy-template
For end-to-end patterns drawn from real services, see the Recipes section.
Quickstart¶
1. Install 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¶
- Framework integrations — aiogram, aiohttp, arq, Celery, FastAPI, FastStream, Flask, gRPC, Litestar, Starlette, taskiq, Typer, Pytest — each builds a scoped child container per request/task/call automatically and closes the APP container at shutdown.
- Resolving — how type-based auto-injection works.
- Factories — the provider you just used.
- Scopes — the APP → REQUEST lifetime model in one page.
- Lifecycle — finalizers,
close_async(), validation. - Recipes — async SQLAlchemy, lifespan-managed resources, testing with overrides.
- Good and bad practices — named footguns and the mechanism that catches each one.