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(myfw.Request, scope=Scope.REQUEST)
myfw_websocket_provider = providers.ContextProvider(myfw.WebSocket, scope=Scope.SESSION)
_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.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.
add_providers is a startup-time operation: concurrent calls on the same root
container are not coordinated beyond the registry's internal lock, so don't
call it from request-handling code running alongside other registrations.
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. Derive the child's scope and context withmodern_di.integrations.classify_connection— it picks the first provider the connection is an instance of and returns its scope + a{context_type: connection}context, orNoneif nothing matches:
from modern_di import integrations
async def build_di_container(connection: HTTPConnection) -> typing.AsyncIterator[Container]:
match = integrations.classify_connection(connection, _CONNECTION_PROVIDERS)
async with fetch_di_container(connection.app).build_child_container(
scope=match.scope if match else None,
context=match.context if match else None,
) as container:
yield container
Container implements both sync and async context-manager protocols
(__enter__/__exit__, __aenter__/__aexit__) — async with/with on a
freshly built child opens it (a no-op, since a freshly built child is already
open) and closes it on exit, equivalent to a try/finally around
close_async/close_sync but without hand-writing it. A freshly built child
is usable immediately either way — open() runs no validation of its own — so
the with/async with here buys guaranteed cleanup on the way out, not a
required open step or any fail-fast check.
For a single connection kind with no dispatch to do, call
integrations.bind(my_provider, connection) directly — it returns the same
scope + context for one provider without the isinstance scan. An adapter
whose unit of work carries no connection object (a Typer command) skips
the kit entirely and calls build_child_container(scope=...) directly. See
How the existing integrations realize the
contract for which
shape fits which adapter.
-
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 that holds a
modern_di.integrations.Marker. Its __call__ receives the request container
(via the framework's own DI) and resolves through the marker:
from modern_di import integrations
@dataclasses.dataclass(slots=True, frozen=True)
class Dependency(typing.Generic[T_co]):
marker: integrations.Marker[T_co]
async def __call__(self, request_container: typing.Annotated[Container, myfw.Depends(build_di_container)]) -> T_co:
return self.marker.resolve(request_container)
def FromDI(dependency: providers.AbstractProvider[T_co] | type[T_co]) -> T_co: # noqa: N802
return typing.cast(T_co, myfw.Depends(Dependency(integrations.Marker(dependency))))
Marker.resolve(container) is a single call, invariant across every
integration and both modes: it hands the wrapped provider-or-type to
container.resolve_dependency, which routes to resolve_provider/resolve
accordingly — overrides, caching, and did-you-mean suggestions are inherited
from whichever it dispatches to. 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 self-heals if reused without reopening — the next resolve emits
ContainerClosedWarningand reopens it — but reopening explicitly on each startup avoids the warning and lets a second lifespan cycle (test client re-entry, broker restart) work cleanly.- 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). Callingopen()on an already-open container is a no-op — it unconditionally clearsclosed, runs no validation, and costs nothing either way.
- 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. - For boot-time fail-fast validation, call
container.validate()aftersetup_di, never before.open()no longer validates anything — a fresh container is already usable, andopen()is now just the symmetric counterpart toclose_*. The ordering constraint that used to attach toopen()attaches tovalidate()instead:setup_diregisters the integration's own connection providers (typically viaadd_providers), so avalidate()call made beforesetup_disees an incomplete graph and raises for any service that depends on the connection object by type — that provider genuinely isn't registered yet. Callingvalidate()aftersetup_disees the complete graph. Validating at all is optional — nothing requires a caller to do it — but document the ordering for whoever does. - Open the root in every execution context the framework runs work in. A
worker may dispatch units of work from more than one place: Celery fires
worker_process_initonly for the prefork/solo pools, never for the gevent / eventlet / threads pools (which run in the main worker process). Wire open/close to a hook that fires for all of them — e.g.worker_init/worker_shutdownin addition to the per-process signals. Where a hook exists, close there so finalizers run at shutdown, and open there too — that reopens silently instead of warning if a previous cycle in the same process already closed the container (a restart). Where no hook fires for a given pool, work still succeeds — the container is already open from construction — but nothing ever closes it, so that pool's finalizers never run.open()andclose_*are idempotent, so overlapping hooks are safe. If the framework offers no lifecycle hook at all, the root's open/close is the caller's to own — document it. On ASGI, the lifespan scope is optional: a mounted sub-application never receives it from its parent, and some deployments disable it (e.g. Mangumlifespan="off") — an app wired there still serves requests (the container is already open), but nothing closes it, sosetup_dibelongs on the top-level served app, or the caller closes the root itself.
Scope mapping¶
Map each connection kind to the scope its child container opens at. Follow the scope hierarchy:
| 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(Marker(...))) |
faststream.Depends(Dependency(Marker(...))) |
Provide(_Dependency(Marker(...))) |
inert Marker (integrations.from_di) + 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. Returnsintegrations.from_di(dependency)— aMarkercast 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 aMarker, 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. -
Use the integration kit instead of hand-rolling the scan and resolve.
integrations.parse_markers(func)is the decoration-time scan;integrations.resolve_markers(container, markers)is the call-time resolve. Both are framework-agnostic — only the signature-rewriting and argument-binding around them (below) is yours to write. If your adapter sweeps an existing app/router to auto-inject handlers (rather than one@injectper handler), guard against double-wrapping withintegrations.is_injected(func)/integrations.mark_injected(wrapper). -
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 by kind (contract point 5), fill them into the call by name, invoke the original function, and
close_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 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.
- Canonical example (
examples/). Ship a runnableexamples/app.py(plus an emptyexamples/__init__.py) demonstrating the recommended wiring: an APP-scopedSettingsplus one work-scoped service that depends on it by type, resolved into a single handler/task/command via the framework's real idiom. Use thetyping.Annotated[T, FromDI(...)]marker form, not a= FromDI(...)default (the default-call form trips ruffB008). Name the example's types to match the integration'sdocs/integrations/<framework>.mdsnippet; diverge only where testability requires it (e.g. return a value the test can assert). Atests/test_example.pysmoke test drives it through the repo's own in-memory test double (test client / eager mode / in-memory broker — whatever the existing tests use) and asserts the real injected output, never a mock. The smoke test must coverexamples/app.pyto 100% under the coverage gate — do not add a coverageomit; mark# pragma: no coveronly on a genuinely unreachable boot line (if __name__ == "__main__"/ server-run). Link it from the README with aUsage example: [examples/](./examples)line directly underFull guide:. - Mirror
modern-di'sCLAUDE.mdandJustfile. Keep behavioural invariants in named tests rather than in a prose truth home, and record rejected alternatives underplanning/decisions/. Keep resolution sync-only and add no runtime dependency beyond the framework andmodern-di.ruffis unpinned and CI floats it forward, so keepCPY001(no per-file copyright header) in the lintignoreand reflow any pre-existing Markdown-embedded code fences the currentruffreformats. - Docs. Add a
docs/integrations/<framework>.mdusage page in themodern-direpo and a nav entry for it inmkdocs.yml(under the matching family group: Web / Tasks & events / Bots / RPC / CLI / Testing). Follow the canonical page shape the existing pages use: a single realistic-but-compact example — an APP-scopedSettingsplus one work-scoped service (two providers, no more) that depends onSettingsby type — with the container built plain (Container(groups=[AppGroup]), novalidate=argument — it's deprecated and does nothing) andcontainer.validate()called explicitly aftersetup_di, demonstrating the ordering rule above. Keep the connection/message object out of that validated example: itsContextProvideris registered bysetup_di, so a service that requires it by type would failvalidate()if that call were placed beforesetup_di. Demonstrate context injection in a dedicated "Framework Context Objects" section instead. To validate a graph that references the connection object beforesetup_dihas registered its provider (a narrower, earlier check), make the context parameter optional (request: FrameworkType | None = None) sovalidate()skips it regardless of ordering, while the integration still injects the real value at runtime — the pattern the gRPC page uses and Framework Context Objects documents. Follow the example with any framework-specific sections, a tailored## See alsoblock linking Testing with overrides, Lifecycle, Scopes, and the most relevant recipe, and the## APItable last. 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 a restart doesn't rely on the
implicit-reuse warning (
ContainerClosedWarning) and gets finalizers wired to shutdown. - [ ]
close_async/close_syncmatches the framework's async-ness. - [ ]
FromDIacceptsAbstractProvider[T] | type[T]and resolves it viaresolve_dependency— usemodern_di.integrations.from_di(or a factory wrappingintegrations.Marker) rather than hand-rolling it. - [ ] 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. - [ ]
examples/app.py(+ smoke test asserting real injected output, 100% coverage, noomit) and a READMEUsage example: [examples/](./examples)line. - [ ]
CLAUDE.mdandJustfilemirrored; invariants pinned by named tests; planning-convention followed.