Skip to content

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:

  1. Own the root container's lifecycle — open it when the app starts, close it when the app stops.
  2. 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.
  3. 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 def that yields the container and closes after. Derive the child's scope and context with modern_di.integrations.classify_connection — it picks the first provider the connection is an instance of and returns its scope + a {context_type: connection} context, or None if 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 BaseMiddleware whose consume_scope builds the child, stashes it in the framework context for the duration of the call, and closes it in finally.

  • Decorator (Typer) — an inject decorator 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. FromDI returns 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. FromDI returns 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 ContainerClosedWarning and 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) and app.after_shutdown(container.close_async). Calling open() on an already-open container is a no-op — it unconditionally clears closed, runs no validation, and costs nothing either way.
  • 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 uses close_sync.
  • For boot-time fail-fast validation, call container.validate() after setup_di, never before. open() no longer validates anything — a fresh container is already usable, and open() is now just the symmetric counterpart to close_*. The ordering constraint that used to attach to open() attaches to validate() instead: setup_di registers the integration's own connection providers (typically via add_providers), so a validate() call made before setup_di sees an incomplete graph and raises for any service that depends on the connection object by type — that provider genuinely isn't registered yet. Calling validate() after setup_di sees 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_init only 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_shutdown in 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() and close_* 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. Mangum lifespan="off") — an app wired there still serves requests (the container is already open), but nothing closes it, so setup_di belongs 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

  • FromDI is inert. Returns integrations.from_di(dependency) — a Marker cast to the resolved type so checkers still see T. On its own it does nothing; the decorator interprets it.
service: typing.Annotated[MyService, FromDI(Dependencies.service)]
  • Decoration time — the decorator introspects typing.get_type_hints(func, include_extras=True), finds parameters whose Annotated metadata holds a Marker, 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 to wrapper.__signature__ — the parser reads that, and functools.wraps alone 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 @inject per handler), guard against double-wrapping with integrations.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_sync the container in finally.

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 per with block, 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 package modern_di_<framework>.
  • Layout.
    • modern_di_<framework>/main.py — the entire implementation.
    • modern_di_<framework>/__init__.py — re-export the public API from main and 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 standard classifiers (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, call setup_di (or install the plugin) with a Container(groups=[Dependencies]), and yield a test client.
    • dependencies.py — a sample Group with Factory providers 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 through FromDI), and test_websockets.py where the framework has websockets. Aim for the same 100%-coverage gate modern-di holds.
  • Canonical example (examples/). Ship a runnable examples/app.py (plus an empty examples/__init__.py) demonstrating the recommended wiring: an APP-scoped Settings plus one work-scoped service that depends on it by type, resolved into a single handler/task/command via the framework's real idiom. Use the typing.Annotated[T, FromDI(...)] marker form, not a = FromDI(...) default (the default-call form trips ruff B008). Name the example's types to match the integration's docs/integrations/<framework>.md snippet; diverge only where testability requires it (e.g. return a value the test can assert). A tests/test_example.py smoke 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 cover examples/app.py to 100% under the coverage gate — do not add a coverage omit; mark # pragma: no cover only on a genuinely unreachable boot line (if __name__ == "__main__" / server-run). Link it from the README with a Usage example: [examples/](./examples) line directly under Full guide:.
  • Mirror modern-di's CLAUDE.md and Justfile. Keep behavioural invariants in named tests rather than in a prose truth home, and record rejected alternatives under planning/decisions/. Keep resolution sync-only and add no runtime dependency beyond the framework and modern-di. ruff is unpinned and CI floats it forward, so keep CPY001 (no per-file copyright header) in the lint ignore and reflow any pre-existing Markdown-embedded code fences the current ruff reformats.
  • Docs. Add a docs/integrations/<framework>.md usage page in the modern-di repo and a nav entry for it in mkdocs.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-scoped Settings plus one work-scoped service (two providers, no more) that depends on Settings by type — with the container built plain (Container(groups=[AppGroup]), no validate= argument — it's deprecated and does nothing) and container.validate() called explicitly after setup_di, demonstrating the ordering rule above. Keep the connection/message object out of that validated example: its ContextProvider is registered by setup_di, so a service that requires it by type would fail validate() if that call were placed before setup_di. Demonstrate context injection in a dedicated "Framework Context Objects" section instead. To validate a graph that references the connection object before setup_di has registered its provider (a narrower, earlier check), make the context parameter optional (request: FrameworkType | None = None) so validate() 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 also block linking Testing with overrides, Lifecycle, Scopes, and the most relevant recipe, and the ## API table 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 green main.

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>, package modern_di_<framework>, main.py + re-exporting __init__.py with explicit __all__.
  • [ ] One connection ContextProvider per connection kind, grouped in a single _CONNECTION_PROVIDERS tuple mapping kind → scope.
  • [ ] setup_di (or a plugin) attaches the root container, registers the connection providers, and wires startup/shutdown.
  • [ ] fetch_di_container reads 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_sync matches the framework's async-ness.
  • [ ] FromDI accepts AbstractProvider[T] | type[T] and resolves it via resolve_dependency — use modern_di.integrations.from_di (or a factory wrapping integrations.Marker) rather than hand-rolling it.
  • [ ] No native DI? FromDI is an inert marker and a decorator rewrites the handler signature (strips DI params, threads the context object, sets wrapper.__signature__), resolves at call time, and closes the per-call container in finally. 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.yml nav entry added in the modern-di repo.
  • [ ] examples/app.py (+ smoke test asserting real injected output, 100% coverage, no omit) and a README Usage example: [examples/](./examples) line.
  • [ ] CLAUDE.md and Justfile mirrored; invariants pinned by named tests; planning-convention followed.