Writing an integration¶
This page is the specification for building a modern-di integration for a framework that does not yet have one (an ASGI app, a message broker, a CLI, a test runner...). It is written to be followed step by step: implement the contract below, mirror the scaffolding, and check every box in the final checklist.
An integration does three jobs and nothing more:
- Own the root container's lifecycle — open it when the app starts, close it when the app stops.
- Open a child container per unit of work — a request, a message, a command — injecting the framework's connection object as context, and close it when that unit ends.
- Bridge modern-di into the framework's own injection — so a handler can ask for a provider or a type and receive the resolved value.
Everything else is framework-specific plumbing to realize those three jobs.
The contract¶
Every integration exposes the following. Types are shown for an async web
framework; swap async/close_async for close_sync in a synchronous one.
1. Connection ContextProvider(s)¶
One module-level provider per connection kind the framework has. Each pairs
the framework's connection type with the scope its
child container should open at. This is the single source of the
kind → scope mapping; setup_di registers them and the child-container builder
dispatches off them.
from modern_di import Scope, providers
myfw_request_provider = providers.ContextProvider(scope=Scope.REQUEST, context_type=myfw.Request)
myfw_websocket_provider = providers.ContextProvider(scope=Scope.SESSION, context_type=myfw.WebSocket)
_CONNECTION_PROVIDERS = (myfw_request_provider, myfw_websocket_provider)
A framework with a single connection kind (a message, a CLI command) has one provider — or, if the unit of work carries no injectable connection object (Typer commands), none at all.
2. setup_di(app, container) -> Container¶
Attach the root container to the framework's application state, register the connection providers, and wire the root container's lifecycle to app startup/shutdown. Return the container.
def setup_di(app: myfw.App, container: Container) -> Container:
app.state.di_container = container # attach
container.providers_registry.add_providers(*_CONNECTION_PROVIDERS) # register
# wire lifecycle (see "Lifecycle rules" below)
return container
Frameworks with a plugin system realize this differently: Litestar ships a
ModernDIPlugin(InitPlugin) whose on_app_init does the same three steps
instead of a free setup_di function. Prefer the framework's idiomatic
extension point.
3. fetch_di_container(app_or_ctx) -> Container¶
Read the root container back out of framework state. This is where the child-container builder and any helpers get at the root.
def fetch_di_container(app: myfw.App) -> Container:
return typing.cast(Container, app.state.di_container)
Store and read under a named constant, not a repeated string literal, when
the framework uses a string-keyed store (FastStream's ContextRepo, Typer's
ctx.obj); it keeps writer and reader in provable agreement.
4. Per-unit-of-work child-container builder¶
Build a child container at the connection's scope, inject the connection object
as context, hand it to the handler, and close it in finally. The shape
depends on how the framework runs handlers:
- Dependency generator (FastAPI, Litestar) — an
async defthatyields the container and closes after:
async def build_di_container(connection: HTTPConnection) -> typing.AsyncIterator[Container]:
context: dict[type[typing.Any], typing.Any] = {}
scope = None
for provider in _CONNECTION_PROVIDERS:
if isinstance(connection, provider.context_type):
context[provider.context_type] = connection
scope = provider.scope
break
container = fetch_di_container(connection.app).build_child_container(context=context, scope=scope)
try:
yield container
finally:
await container.close_async()
-
Middleware (FastStream) — a
BaseMiddlewarewhoseconsume_scopebuilds the child, stashes it in the framework context for the duration of the call, and closes it infinally. -
Decorator (Typer) — an
injectdecorator that wraps the command, opens a child container for the command's duration, resolves the marked parameters, and closes the container (synchronously) on exit.
5. FromDI marker + Dependency resolver¶
FromDI(dependency) accepts a provider or a type and, at a handler's call
site, stands in for the resolved value: x: Annotated[Foo, FromDI(foo_provider)].
How it delivers that value splits into two modes depending on the framework:
- Native-DI frameworks (FastAPI, FastStream, Litestar) have a per-handler
injection seam —
Depends,Provide.FromDIreturns that native marker and the framework calls your resolver with the request container. This is the path documented below. - Frameworks with no request-scoped DI (Typer/Click CLIs, argparse, task
runners) have no seam.
FromDIreturns an inert marker and a decorator does the resolution. See Frameworks without native DI.
For the native-DI path, FromDI returns the framework's injection marker
wrapping a frozen, slotted dataclass whose __call__ receives the request
container (via the framework's own DI) and dispatches on the argument kind.
@dataclasses.dataclass(slots=True, frozen=True)
class Dependency(typing.Generic[T_co]):
dependency: providers.AbstractProvider[T_co] | type[T_co]
async def __call__(self, request_container: typing.Annotated[Container, myfw.Depends(build_di_container)]) -> T_co:
if isinstance(self.dependency, providers.AbstractProvider):
return request_container.resolve_provider(self.dependency)
return request_container.resolve(dependency_type=self.dependency)
def FromDI(dependency: providers.AbstractProvider[T_co] | type[T_co]) -> T_co: # noqa: N802
return typing.cast(T_co, myfw.Depends(Dependency(dependency)))
The two dispatch arms are invariant across every integration and both modes:
resolve_provider for an AbstractProvider, resolve for a bare type.
FromDI is spelled in PascalCase (with # noqa: N802) because it stands in for
a type at call sites.
Lifecycle rules¶
- Reopen the root container on startup. A container that was closed on
shutdown raises
ContainerClosedErrorif reused. Reopening on each startup lets a second lifespan cycle (test client re-entry, broker restart) work.- With a context-manager lifespan:
async with fetch_di_container(app): yield—__aenter__reopens,__aexit__closes. Compose around any existing lifespan rather than replacing it. - With callback hooks:
app.on_startup(container.open)andapp.after_shutdown(container.close_async). Reopening an already-open container is a no-op.
- With a context-manager lifespan:
- Always close the child container in
finally. Never leak a unit-of-work container on the error path. - Match async vs sync to the framework. Async frameworks use
close_async; a synchronous CLI usesclose_sync.
Scope mapping¶
Map each connection kind to the scope its child container opens at. Follow the
scope hierarchy (APP < SESSION < REQUEST < ACTION <
STEP):
| Unit of work | Scope | Rationale |
|---|---|---|
| HTTP request | REQUEST |
one child per request |
| WebSocket connection | SESSION |
outlives individual messages on the socket |
| Broker message | REQUEST |
one child per consumed message |
| CLI command | REQUEST |
one child per command invocation |
| Nested action within a unit | ACTION (a further child) |
opt-in deeper scope, e.g. a Typer action_scope |
How the existing integrations realize the contract¶
Pattern-match your framework to the closest precedent.
| Contract point | FastAPI | FastStream | Litestar | Typer |
|---|---|---|---|---|
| Root attach + lifecycle | setup_di + composed lifespan |
setup_di + on_startup/after_shutdown callbacks |
ModernDIPlugin.on_app_init + lifespan |
setup_di via ctx.obj |
| Fetch root | app.state.di_container |
context.get("di_container") |
app.state.di_container |
ctx.obj["di_container"] |
| Connection providers | request + websocket | message | request + websocket | none (command has no connection object) |
| Child builder | async dependency generator |
BaseMiddleware.consume_scope |
async dependency generator |
inject decorator |
FromDI bridge |
fastapi.Depends(Dependency(...)) |
faststream.Depends(Dependency(...)) |
Provide(_Dependency(...)) |
inert _FromDI marker + inject |
| Child close | close_async |
close_async |
close_async |
close_sync |
The Starlette integration (modern-di-starlette) is the
reference for a middleware + decorator hybrid: Starlette has no native DI,
so a pure-ASGI middleware owns the child-container lifecycle (like FastStream)
while an @inject decorator with an inert FromDI marker does resolution (like
Typer). It splits the two responsibilities of the decorator path — the middleware
builds and closes the per-connection child, the decorator only reads it back from
the ASGI scope and resolves. See Frameworks without native
DI.
The aiohttp integration (modern-di-aiohttp) is another
middleware + decorator hybrid, for a non-ASGI server where the only connection
object at middleware entry is web.Request — a WebSocket is an upgraded HTTP
request, not a distinct type. It detects a WebSocket via
web.WebSocketResponse().can_prepare(request).ok, opens a Scope.REQUEST
child for an HTTP request or a Scope.SESSION child for a WebSocket, and —
because both connection providers bind web.Request — registers
aiohttp_request_provider by type while keeping aiohttp_websocket_provider
reference-only (bound_type=None). Its root lifecycle rides aiohttp's
on_startup/on_cleanup signals rather than a composed lifespan.
The pytest integration
(modern-di-pytest) is a different shape: it has no app to wire, so
instead of setup_di/FromDI it exposes modern_di_fixture (turn one
dependency into a fixture) and expose (turn a Group's providers into
fixtures). It resolves from a user-supplied di_container fixture. Follow it
when integrating a test runner rather than an application framework.
Frameworks without native DI (the decorator path)¶
Contract points 4 and 5 assume a per-handler injection seam — FastAPI /
FastStream Depends, Litestar Provide — that you hand a native marker and
that calls your resolver with the request container. Some frameworks have none:
a Typer/Click command, an argparse handler, or a plain task callable receives
only what the framework's argument parser binds. There is nowhere to inject.
For these, FromDI becomes an inert annotation marker and a decorator does
the work native DI would have. modern-di-typer's @inject is the
reference implementation — reach for this shape whenever the framework runs
handlers as plain callables it parses arguments for. The decorator can build the
per-call child container itself (Typer), or read one built by middleware
(modern-di-starlette builds it in a pure-ASGI middleware and the
decorator only resolves from it) — the resolution mechanics below are the same
either way.
How it works¶
FromDIis inert. It returns a frozen_FromDI(provider)dataclass, cast to the resolved type so checkers still seeT. On its own it does nothing; the decorator interprets it.
-
Decoration time — the decorator introspects
typing.get_type_hints(func, include_extras=True), finds parameters whoseAnnotatedmetadata holds a_FromDI, then rewrites the signature: remove those parameters (so the arg parser never treats them as CLI options) and insert the framework's context parameter (typer.Context) at position 0 if the handler didn't declare one. Assign the cleaned signature towrapper.__signature__— the parser reads that, andfunctools.wrapsalone won't set it. -
Call time — bind incoming args against the rewritten signature, pull out the context object (deleting it again if the decorator added it implicitly), build the per-call child container, resolve each marked parameter (
resolve_providerfor providers,resolvefor bare types), fill them into the call by name, invoke the original function, andclose_syncthe container infinally.
DI parameters coexist with ordinary framework parameters because the decorator strips only the marked ones; everything else still reaches the parser.
What changes vs. the native path¶
| Contract point | Native DI | Decorator |
|---|---|---|
FromDI returns |
framework marker (Depends / Provide) |
inert _FromDI marker |
| Child container built by | framework, via your resolver | the decorator wrapper |
| Handler receives value via | framework's DI | signature rewrite + fill-by-name at call time |
| Root-container access | connection object passed in | framework's per-call context, injected into the signature if absent |
Connection ContextProvider |
one per connection kind | none — the handler carries no connection object |
Pitfalls to get right¶
- Set
wrapper.__signature__. Without it the parser still sees the stripped DI params and errors. (__signature__isn't in the stub, so# ty: ignore[unresolved-attribute].) - Strip only DI params. Leave real arguments/options in the signature or the framework stops parsing them.
- Decorator order. The framework's own registration decorator goes
outside —
@app.command()above@inject— so it registers the rewritten signature. - Isolate per-call state. Stash the per-call container on a per-invocation
store (
ctx.meta), not shared app state (ctx.obj), so nested scopes can parent onto it and nothing leaks between invocations. - Keep nested scopes caller-driven. Expose a helper (
action_scope(ctx)) that yields a fresh deeper-scope child of the per-call container perwithblock, rather than auto-injecting one.
Repo scaffolding¶
Each official integration is its own repository and PyPI package, mirroring the
modern-di repo's tooling.
- Names. Repo and PyPI package
modern-di-<framework>; import packagemodern_di_<framework>. - Layout.
modern_di_<framework>/main.py— the entire implementation.modern_di_<framework>/__init__.py— re-export the public API frommainand list it in an explicit__all__(this is the integration's surface; keep private helpers out of it).
pyproject.toml.name = "modern-di-<framework>",description = "modern-di integration for <Framework>", dependencies["<framework>>=...,<...", "modern-di>=<current>,<3"], the standardclassifiers(Typed, supported Python versions) and[project.urls]pointing at the shared docs site and the integration's own repo.version = "0"— the release tag sets it.- Tests (
tests/):conftest.py— fixtures that build an app, callsetup_di(or install the plugin) with aContainer(groups=[Dependencies]), and yield a test client.dependencies.py— a sampleGroupwithFactoryproviders at several scopes, plus providers that read the connection object (e.g. a request header) to prove context injection works.test_lifespan.py(startup/shutdown + restart),test_routes.py/test_commands.py(resolution throughFromDI), andtest_websockets.pywhere the framework has websockets. Aim for the same 100%-coverage gatemodern-diholds.
- Mirror
modern-di'sCLAUDE.md,Justfile, andarchitecture/truth home. Keep resolution sync-only and add no runtime dependency beyond the framework andmodern-di. - Docs. Add a
docs/integrations/<framework>.mdusage page in themodern-direpo and a nav entry for it inmkdocs.yml; integrations do not ship their own docs site. - Release. Tag-driven, mirroring
modern-di: write release notes and push a bare semver tag off greenmain.
Planning convention
For the planning/change-management setup, following the
planning-convention is
recommended — the same two-axis convention the modern-di repo uses.
Checklist¶
- [ ] Repo
modern-di-<framework>, packagemodern_di_<framework>,main.py+ re-exporting__init__.pywith explicit__all__. - [ ] One connection
ContextProviderper connection kind, grouped in a single_CONNECTION_PROVIDERStuple mapping kind → scope. - [ ]
setup_di(or a plugin) attaches the root container, registers the connection providers, and wires startup/shutdown. - [ ]
fetch_di_containerreads the root container back out of framework state. - [ ] A per-unit-of-work builder opens a child container at the right scope,
injects the connection as context, and closes it in
finally. - [ ] Root container reopens on startup so restarts don't raise
ContainerClosedError. - [ ]
close_async/close_syncmatches the framework's async-ness. - [ ]
FromDIacceptsAbstractProvider[T] | type[T]and dispatchesresolve_providervsresolve. - [ ] No native DI?
FromDIis an inert marker and a decorator rewrites the handler signature (strips DI params, threads the context object, setswrapper.__signature__), resolves at call time, and closes the per-call container infinally. See the decorator path. - [ ] Tests cover lifespan (incl. restart), resolution through
FromDI, and context injection from the connection object; coverage gate green. - [ ] Usage page +
mkdocs.ymlnav entry added in themodern-direpo. - [ ]
CLAUDE.md,Justfile,architecture/mirrored; planning-convention followed.