# modern-di Powerful dependency-injection framework with IoC container and scopes. # Quickstart # `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: - Litestar — [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template) - FastAPI — [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template) For end-to-end patterns drawn from real services, see the [Recipes](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) section. ______________________________________________________________________ # Quickstart ## 1. Install `modern-di` ```bash uv add modern-di ``` ```bash pip install modern-di ``` ```bash 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. ```python 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. ```python 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. ```python 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](https://modern-di.modern-python.org/providers/lifecycle/index.md). ## Where to next - Framework integrations — [aiogram](https://modern-di.modern-python.org/integrations/aiogram/index.md), [aiohttp](https://modern-di.modern-python.org/integrations/aiohttp/index.md), [arq](https://modern-di.modern-python.org/integrations/arq/index.md), [Celery](https://modern-di.modern-python.org/integrations/celery/index.md), [FastAPI](https://modern-di.modern-python.org/integrations/fastapi/index.md), [FastStream](https://modern-di.modern-python.org/integrations/faststream/index.md), [Flask](https://modern-di.modern-python.org/integrations/flask/index.md), [gRPC](https://modern-di.modern-python.org/integrations/grpc/index.md), [Litestar](https://modern-di.modern-python.org/integrations/litestar/index.md), [Starlette](https://modern-di.modern-python.org/integrations/starlette/index.md), [taskiq](https://modern-di.modern-python.org/integrations/taskiq/index.md), [Typer](https://modern-di.modern-python.org/integrations/typer/index.md), [Pytest](https://modern-di.modern-python.org/integrations/pytest/index.md) — each builds a scoped child container per request/task/call automatically and closes the APP container at shutdown. - [Resolving](https://modern-di.modern-python.org/introduction/resolving/index.md) — how type-based auto-injection works. - [Factories](https://modern-di.modern-python.org/providers/factories/index.md) — the provider you just used. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model in one page. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers, `close_async()`, validation. - [Recipes](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — async SQLAlchemy, lifespan-managed resources, testing with overrides. - [Good and bad practices](https://modern-di.modern-python.org/recipes/good-and-bad-practices/index.md) — named footguns and the mechanism that catches each one. # Introduction # What is Dependency Injection? Dependency Injection (DI) is a design pattern where dependencies are provided (injected) from outside rather than created inside a class. ## The Problem Without DI, classes create their own dependencies, leading to tight coupling: ```python class UserService: def __init__(self) -> None: self.email = EmailService() # ❌ Tight coupling def register_user(self, email: str) -> None: self.email.send_email(email, "Welcome!") ``` **Issues:** Hard to test, can't swap implementations, hidden dependencies. ## The Solution With DI, dependencies are injected from outside: ```python class UserService: def __init__(self, email: EmailSender) -> None: # ✅ Injected self.email = email def register_user(self, email: str) -> None: self.email.send_email(email, "Welcome!") ``` **Benefits:** Easy testing, loose coupling, explicit dependencies. ## Why Use Dependency Injection? ### 1. Testability Inject mocks for testing: ```python def test_user_service() -> None: mock_email = Mock(spec=EmailSender) service = UserService(email=mock_email) service.register_user("test@example.com") mock_email.send_email.assert_called_once() ``` ### 2. Loose coupling Depend on abstractions, not concrete implementations, so the class using them never changes when you swap `RedisCache` for `DictCache` in development or `MockCache` in tests. ## Manual wiring doesn't scale As an app grows, someone has to build every object by hand, in the right order: ```python config = AppConfig() db = DatabaseConnection(config) email = EmailService(config) user_service = UserService(db, email) ``` This is unwieldy at scale, has no lifetime management, and scatters construction logic wherever a dependency is needed. A DI container takes over that construction: `modern-di` reads your classes' type hints and builds the graph for you — see the [Quickstart](https://modern-di.modern-python.org/#2-first-success). ## Lifetime management Objects can have different lifetimes — singleton, per-request, or a fresh instance every call. `modern-di` expresses this with [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md): a provider's scope decides how long its instances live, and `cache=True` decides whether an instance is shared or rebuilt on each resolve — see [Cached factories](https://modern-di.modern-python.org/providers/factories/#cached-factories). ## See also - [modern-di vs other libraries](https://modern-di.modern-python.org/introduction/comparison/index.md) — including whether you need a container at all. - [Quickstart](https://modern-di.modern-python.org/index.md) — modern-di's own syntax, end to end. - [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/index.md) — the reasoning behind the API's choices. # Resolving dependencies `modern-di` exposes two ways to resolve a dependency: - **By type** — `container.resolve(SomeType)`. The resolver finds the provider whose `bound_type` matches `SomeType`. This is what handlers and creator signatures normally use. - **By provider reference** — `container.resolve_provider(Dependencies.some_provider)`. Resolves a specific provider directly, skipping the type lookup. Useful in tests and when two providers produce the same type. In practice, prefer resolution by type — it lets the same code work whether you swap implementations via subclassing, `Alias`, or `override`. Reach for `resolve_provider` only when type-based resolution would be ambiguous. ## Automatic sub-dependency resolution A `Factory`'s creator function or class constructor is introspected at declaration time. For each parameter with a type annotation, the resolver looks for a provider whose `bound_type` matches and injects the resolved value. Parameters with default values fall back to those defaults if no provider matches. ```python import dataclasses from modern_di import Container, Group, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class DatabaseConfig: host: str port: int @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class DatabaseConnection: config: DatabaseConfig # auto-resolved by type timeout: int = 30 # uses default if unresolvable class Dependencies(Group): db_config = providers.Factory( DatabaseConfig, scope=Scope.APP, kwargs={"host": "localhost", "port": 5432}, ) db_connection = providers.Factory(DatabaseConnection, scope=Scope.APP) container = Container(groups=[Dependencies]) connection = container.resolve(DatabaseConnection) assert connection.config.host == "localhost" assert connection.timeout == 30 ``` For union-typed parameters (`dep: A | B`), the resolver picks the *first* type in the union that has a registered provider. If you need a specific one, use a concrete annotation or pass the value explicitly via `kwargs`. A parameter typed `X | None` with no matching provider and no default value receives `None` rather than raising (see [Factories: Optional parameters](https://modern-di.modern-python.org/providers/factories/index.md)). ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the scope chain governs which container resolves which provider. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — `container.validate()` catches resolution problems at startup. - [Factories: `bound_type`](https://modern-di.modern-python.org/providers/factories/index.md) — how the type lookup key is set, and how to opt out. # Design decisions `modern-di` is opinionated. These are the deliberate choices behind the API so you can decide whether the framework matches your project. ## 1. Resolution is sync-only; finalizers may be sync or async Since 2.x, `Container.resolve(...)` and `resolve_provider(...)` are synchronous. There is no `await container.resolve(...)`, no `AsyncFactory`, no `AsyncSingleton`. Async work belongs in the framework's lifespan and per-request hooks; the container holds the already-constructed objects (see [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md)). Resolution being sync does not mean teardown is: finalizers may be sync or async (`close_sync` / `close_async`), so async cleanup is fully supported. This is a permanent choice, not a temporary limitation. There are no plans to reintroduce async resolution. ## 2. Cached factories are thread-safe Cached `Factory` providers use a per-container reentrant lock (`threading.RLock`) so concurrent resolves in multiple threads still produce exactly one instance per cache. Single-threaded apps can disable the lock with `Container(..., use_lock=False)` for a small performance gain; multi-threaded apps must leave it on. ### The thread-safety boundary - **Cached / singleton creation is locked.** The per-container reentrant lock guards the create-and-store step, so two threads racing to resolve the same cached provider get the same single instance. - **Provider registration is safe.** `ProvidersRegistry` mutations (`register`, `add_providers`) are guarded by the registry's own lock, and iteration snapshots the provider dict (`iter(list(...))`), so registering providers concurrently — or while another thread iterates — will not corrupt the registry or raise "dict changed size during iteration". - **Registration is a setup phase, not a coordination tool.** The registry is lock-guarded against corruption, but the supported model is register every provider *before* serving. Registering a provider while other threads are already resolving is timing-dependent by nature — nothing breaks, but whether a given resolve sees the new provider is undefined. - **`set_context` and overrides are last-write-wins.** Both write into a per-container dict with no ordering, queueing, or merge; concurrent writes to the same key keep whichever landed last. Do them during setup, or per-request on a request-local child container — never from competing threads. - **Free-threaded CPython (PEP 703) is supported at `2 - Beta`.** Production-ready and tested under real multithreading on the `3.14t` build. It is Beta rather than Stable for one specific reason: modern-di relies on object-publication ordering — that a reader observing a stored reference sees fully-initialized fields — and CPython publishes no memory model, so that is implementation behaviour rather than a spec guarantee. Throughput also does not scale across cores; per-op latency is competitive, but atomic reference counting of the objects every resolve shares tracks the GIL. ## 3. No global state All state — resolved instances, context values, overrides — lives in container registries. There is no module-level container, no `current_container()`, no thread-local singleton. You explicitly create a `Container` and pass it (or its children) where it needs to go. Framework integrations handle this for you. ## 4. Maximum type safety The codebase is type-checked with `ty` and linted with ruff's full rule set (`select = ["ALL"]`). Escape hatches (`typing.cast`, `ty: ignore`) are rare and localized — a handful across the whole library. Provider types parameterize on the resolved type, so type checkers infer the right thing without help. ## 5. Conservative feature set New features get added only when existing primitives genuinely cannot solve the task. The core has three concrete provider types (`Factory`, `Alias`, `ContextProvider`), plus the `AbstractProvider` base and the pre-built `container_provider` singleton — most other DI frameworks have two to three times that. This is deliberate: a small, composable core is easier to learn, easier to test, and easier to keep correct. ## Non-goals Beyond the choices above, four more things are deliberately out of scope. Naming them here is meant to save you from filing (or us from re-litigating) the same feature request. ### Auto-binding / auto-registration **What:** modern-di never registers a provider for a type you didn't declare, and never infers wiring by scanning your codebase (import scanning, decorator scanning, `auto_bind`-style fallbacks some frameworks offer). **Why:** Auto-binding defers a missing-provider error from declaration time — where modern-di already raises `UnsupportedCreatorParameterError` — to whichever request first exercises the untested path. That's the opposite of the framework's declaration-time-failure bet, and it invites automagic wiring nobody can trace back to a source. **Alternative:** Register the provider explicitly in a `Group`. If the boilerplate is real, write a small helper that builds several `Factory` instances from a list of classes — that's application code, not a framework feature. ### In-package framework integrations **What:** The core `modern-di` package ships no framework-specific code. Each integration (aiohttp, FastAPI, FastStream, Litestar, Starlette, Typer, Flask, gRPC, Celery, arq, taskiq, aiogram, pytest) is a separate `modern-di-*` package with its own release cadence. **Why:** Bundling integrations into core would couple the library's release cadence to every framework's own churn, and would erode the zero-dependency guarantee that lets `modern-di` itself stay dependency-free. The separate-repo model is a standing architectural decision (see [`writing-integrations.md`](https://modern-di.modern-python.org/integrations/writing-integrations/index.md)), not an oversight. **Alternative:** Install the matching adapter package — see the [Quickstart](https://modern-di.modern-python.org/index.md) for the current list — or write your own following [Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/index.md). ### Graph rendering / visualization tooling **What:** modern-di has no built-in way to render the dependency graph as a picture — no ASCII art, no bundled renderer, no `plot()`/`render()` call, no image output. **Why:** Rendering is a standalone subsystem (choosing, drawing, and maintaining a diagram toolchain) rather than an extension of an existing primitive, so it sits outside the conservative feature set and the zero-dependency guarantee. `validate()`'s aggregated, all-errors-at-once text report already surfaces the graph's problems without a new dependency or output format. **Alternative:** None shipped today. If you need a picture of the graph, walk `Group.get_providers()` yourself and feed the edges to the diagram tool of your choice. ### Static / compile-time wiring verification (a type-checker plugin) **What:** modern-di ships no static dependency-graph checker and no type-checker plugin (mypy, pyright, or `ty`). Whole-graph verification is the opt-in runtime [`validate()`](https://modern-di.modern-python.org/providers/lifecycle/index.md), which walks the graph for missing providers, scope-direction violations, and cycles and reports them all at once — on top of the declaration-time `UnsupportedCreatorParameterError` that already fires when a creator can't be wired. **Why:** In the wider field, true compile-time wiring checks are a property of compiled-language toolchains — Dagger's annotation processor, Google Wire's codegen, Koin's K2 compiler plugin — and where they exist they *replace* runtime verification rather than extend it (Koin's docs tell users to delete their `verify()` tests). A Python type-checker plugin can't cheaply emulate that: pyright supports no third-party plugins by design, `ty` (which modern-di itself uses) has none either, and only mypy exposes one — a plugin API its own docs call experimental, changed without deprecation. Such a plugin would serve only mypy users, duplicate `validate()`, and not even help modern-di's own toolchain. **Alternative:** Call `container.validate()` explicitly in a startup path or a single test — it is runtime, so it works identically under mypy, pyright, and `ty`, with no plugin to install. ## See also - [About DI](https://modern-di.modern-python.org/introduction/about-di/index.md) — the framework-agnostic introduction. - [Migration from `that-depends`](https://modern-di.modern-python.org/migration/from-that-depends/index.md) — what these decisions changed compared to the older framework. # modern-di vs other libraries modern-di isn't the only way to do dependency injection in Python. This is an honest look at where it fits — including when you don't need a DI container at all. ## Do you even need a DI container? If you're building a single FastAPI or Litestar service and everything you inject is request-scoped (a database session, the current user, settings), the framework's own DI — FastAPI's `Depends`, Litestar's `Provide` — is enough, and a standalone container is overkill. Reach for a container when one of these is true: - **More than one entrypoint.** An API *and* a worker (FastStream/Celery) *and* a CLI (Typer), all sharing one wiring instead of three parallel copies. - **Typed, app-scoped singletons with real teardown** — instead of an untyped `app.state` bag plus `lru_cache` with no cleanup. - **Resolution off the request path** — in startup, background tasks, workers, or CLI commands, where `Depends`/`Provide` simply don't run. - **Whole-app test overrides** — swap a dependency once and have every entrypoint (HTTP, worker, CLI, direct unit tests) see it, not just code reached through the HTTP layer. modern-di's core promise is exactly that: **one typed wiring shared across a dozen frameworks — aiohttp, FastAPI, Litestar, FastStream, Starlette, Typer, Flask, gRPC, Celery, arq, taskiq, and aiogram.** ## The landscape | | modern-di | Dishka | dependency-injector | injector | FastAPI `Depends` | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------- | -------------------------------------- | ------------------ | | Style | type-based autowiring | type-based autowiring (provider classes) | declarative containers + markers | Guice-style `@inject` | callable-based | | Scopes | APP→…→STEP + any IntEnum | RUNTIME→…→STEP (+ custom) | lifetimes (Singleton/Factory/Resource) | Singleton / Thread / None | request only | | Resolution | sync (async finalizers supported) | sync + async | sync + async | sync | async | | First-party pytest plugin | ✅ | ✘ | ✘ | ✘ | n/a | | Integrations | 12 official frameworks (aiogram, aiohttp, arq, Celery, FastAPI, FastStream, Flask, gRPC, Litestar, Starlette, taskiq, Typer) + a pytest plugin | 13 official frameworks + ~10 community-maintained | aiohttp, Flask, Starlette; FastAPI via wiring | Flask (1st-party), FastAPI (3rd-party) | n/a | | Typed resolution | ✅ | ✅ | partial | ✅ | callable-keyed | | License | MIT | Apache-2.0 | BSD-3 | BSD-3 | — | | Adoption | newest, very active | established, large community | most popular, mature | mature | built into FastAPI | On the **typed-resolution** row: modern-di keeps the concrete static type end to end. `resolve(SomeType)` is typed `SomeType` (not `Any`), and the injection marker for integrations, `Annotated[T, from_di(dep)]`, type-checks as `T` — the same clean shape as Dishka's `FromDishka[T]` and FastAPI's `Annotated[T, Depends(...)]`. That is a deliberate design point, not an accident: the older marker spellings erase the type — `dependency-injector`'s `Provider[Animal]` annotation infers the base `Animal` rather than a concrete subtype, and a bare `x = Depends(fn)` is typed `Any`. It also needs no type-checker plugin to hold — see the [non-goal on static wiring verification](https://modern-di.modern-python.org/introduction/design-decisions/#non-goals). ## Honest comparison ### vs Dishka Dishka is the closest library to modern-di — also typed, also scopes-first, also integrating with FastAPI and Litestar — and it's more established, with a larger community and a wider integration surface: 13 official framework integrations, plus the ~10 community-maintained ones it links from its own docs. Its FastStream and Starlette support are two of those community packages ([`dishka-faststream`](https://github.com/faststream-community/dishka-faststream) and [`starlette-dishka`](https://github.com/reagento/starlette-dishka)); the bundled `dishka.integrations` modules for both are deprecated in favor of them. If you need **arbitrary *named* scopes** or **async resolution**, Dishka is an excellent choice — as it is if you need an integration modern-di doesn't have yet: aiogram-dialog, Click, Sanic and telebot officially, or Pyramid, Quart, RQ, Strawberry and APScheduler from the community. modern-di's deliberate differences: - **A first-party pytest plugin** (`modern-di-pytest`) that turns any dependency into a fixture — Dishka ships no pytest *plugin*, documenting a hand-written fixtures recipe instead. - **Sync-only *resolution* (async finalizers still supported) and a small, built-in scope chain you can still extend with any `IntEnum`** — a simpler model. Dishka's own docs note that custom scopes are "hardly ever needed," which is the honest case for modern-di's simpler design. See [Custom scopes](https://modern-di.modern-python.org/providers/scopes/#custom-scopes). - **All-official, uniformly-maintained integrations** under a single MIT-licensed project, as part of the broader [modern-python](https://github.com/modern-python) stack. ### vs dependency-injector `dependency-injector` is the most popular Python DI library, with a mature, Cython-accelerated core and a declarative style using `Provide[...]` markers and `@inject`. It is actively maintained again after an earlier hiatus. modern-di differs in style — **type-based autowiring instead of explicit markers** — and adds **nested request scopes** and a **first-party pytest plugin**. If you prefer explicit declarative wiring and the largest ecosystem, dependency-injector is a solid, proven choice. Migrating an existing codebase? See the [migration guide](https://modern-di.modern-python.org/migration/from-dependency-injector/index.md) for the full provider-by-provider mapping. ### vs injector `injector` is a Guice-inspired, mature library with `@inject` and `Module`-based configuration. Its core has **no async support** and **no nested request scope** (request scoping comes from third-party FastAPI adapters). modern-di offers built-in scopes, official framework integrations, and resource finalization out of the box. ### vs framework-native (`Depends` / `Provide`) For a single web service, native DI is simpler and a container is overkill — see [Do you even need a DI container?](#do-you-even-need-a-di-container) above. modern-di earns its place once you have a second entrypoint, or need typed, scoped, app-wide singletons with overrides that work everywhere, not just on the HTTP path. ## that-depends or modern-di? [`that-depends`](https://github.com/modern-python/that-depends) is a sibling project from the same author, in the same [modern-python](https://github.com/modern-python) family — it isn't in the table above because the choice between the two isn't about features so much as which generation of the same design you want. - **Starting a new project?** Use **modern-di**. It has explicit scopes, no global state, a small strictly-typed core, and separate framework adapters — see [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/index.md). - **Already using that-depends?** It remains **actively maintained and production-proven** — you don't need to migrate. Move when you want explicit scopes or a no-global-state architecture; the [migration guide](https://modern-di.modern-python.org/migration/from-that-depends/index.md) maps every concept across. | | that-depends | modern-di | | --------------- | ---------------------------------------------- | ------------------------------------------------------- | | Resolution | async + sync (`AsyncFactory`, `await resolve`) | sync resolution (async finalizers supported) | | Container model | the container class is both schema and runtime | `Group` (schema) and `Container` (runtime) are separate | | Scopes | context-based lifetimes | explicit, enforced scope chain (APP→…→STEP) | | Global state | resolves directly from the container class | none — you create and pass containers explicitly | | Integrations | bundled | separate adapter packages (install only what you need) | Choose **that-depends** if you specifically want async resolution (`await container.resolve(...)` — modern-di is sync-only by design and won't add it), want the simplest setup for a single service without an explicit scope chain, or already run it in production with no reason to change. The [migration guide](https://modern-di.modern-python.org/migration/from-that-depends/index.md) covers every provider type and concept, including the conceptual shifts: the schema/runtime split (`Group` vs `Container`), sync-only resolution, and explicit scopes. ## Where is Singleton? Cross-framework vocabulary modern-di deliberately has no `Singleton` class — "create once and reuse" is spelled via a scope plus `cache=True` on an ordinary `Factory`. Every arriving user speaks a different framework's lifetime dialect, so here is how the same six concepts translate: | Concept | dependency-injector | dishka | wireup | svcs | FastAPI `Depends` | modern-di | | -------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | Singleton (create once, share) | `providers.Singleton(...)` | `provide(Impl, scope=Scope.APP)` — cached by default within its scope | `@injectable` — default `lifetime="singleton"` | `registry.register_value(Type, value)` at startup | a dependency wrapped in `@lru_cache` | [`Factory(..., scope=Scope.APP, cache=True)`](https://modern-di.modern-python.org/providers/factories/#cached-factories) | | Transient (fresh instance every time) | `providers.Factory(...)` | `provide(Impl, cache=False)` | `@injectable(lifetime="transient")` | no dedicated provider — call the plain factory directly | `Depends(fn, use_cache=False)` | a plain [`Factory(...)`](https://modern-di.modern-python.org/providers/factories/index.md) with no `cache` | | Request-scoped | `providers.Resource` + the `Closing` wiring marker | `provide(Impl, scope=Scope.REQUEST)` | `@injectable(lifetime="scoped")` | one instance per `svcs.Container` (built per request) | bare `Depends(fn)` — computed once per request by default | [`Factory(..., scope=Scope.REQUEST, cache=True)`](https://modern-di.modern-python.org/providers/scopes/index.md) | | Runtime value (request object, etc.) | `providers.Configuration` / `.from_value()` | `from_context(provides=Type, scope=...)` declared, then `context={Type: value}` at scope entry | a typed constructor parameter resolved from the active scope's context | `registry.register_value(Type, value)`, or a per-container local factory | the framework injects `Request`/`WebSocket` directly by type | [`ContextProvider(...)`](https://modern-di.modern-python.org/providers/context/index.md) + `context={...}` | | Interface binding (concrete → abstract type) | `providers.AbstractFactory` — must be overridden with a concrete `Factory` before use | `alias(source=Impl, provides=Interface)` | `@injectable(as_type=Interface)` | `register_factory(Interface, factory)` — svcs keys by whatever type you register under | n/a — `Depends` is callable-keyed, not type-keyed | [`Alias(Impl, bound_type=Interface)`](https://modern-di.modern-python.org/providers/alias/index.md) | | Test override | `provider.override(...)`, or `with provider.override(...):` | no dedicated API — build a separate container from mock providers | `with container.override.injectable(Target, new=fake):` | re-call `register_value()`/`register_factory()`; `container.close()` first if already cached | `app.dependency_overrides[dep] = fake` | [`container.override(provider, mock)`](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) | ## See also - [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/index.md) — the reasoning behind sync-only resolution, no global state, a conservative core, and the deliberate [non-goals](https://modern-di.modern-python.org/introduction/design-decisions/#non-goals) that keep it that way. - [Performance](https://modern-di.modern-python.org/introduction/performance/index.md) — comparative benchmarks: how fast resolution is versus other DI frameworks, and the method behind the numbers. # Performance This page compares modern-di's resolution performance against four other Python DI frameworks, states the method, and gives a command to reproduce the numbers. modern-di has no runtime dependencies and generates no code. The comparison set includes two frameworks that use `exec` codegen (dishka, wireup), one with a Cython-compiled core (dependency-injector), and one pure-Python framework (that-depends). > Absolute timings depend on the machine and CPython build and will differ on yours. The ratios between frameworks are more portable across machines, so the tables below are expressed as ratios. ## What is measured Five scenarios, each the smallest graph that isolates one cost, run with [`pytest-benchmark`](https://pytest-benchmark.readthedocs.io/) in an isolated environment with pinned rival versions: | ID | Scenario | Isolates | | --- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | C1 | Transient resolve, single dependency | pure wiring cost | | C2 | Singleton resolve, warm cache | cache-hit lookup | | C3 | Deep chain, depth 6 | per-edge wiring | | C4 | Request lifecycle: enter scope → resolve → async-finalize on exit | whole per-request cost | | C6 | Per-request context: supply a runtime value by type, resolve a handler that needs it plus an app-scoped dep | the request-injection path every integration uses | Each framework uses its own idiomatic request-scope and resource-teardown spelling, not modern-di's names forced onto it. Full per-framework mapping and rules: [`benchmarks/README.md`](https://github.com/modern-python/modern-di/blob/main/benchmarks/README.md). C1-C3 are published twice for modern-di: once resolved by provider reference (`resolve_provider`) and once by type (`resolve`). dishka and wireup expose only by-type lookup; that-depends and dependency-injector only by-reference. Each C1-C3 table compares one modern-di variant against the rivals whose API matches it, because a single column would flatter modern-di against half the set. By-type resolution adds a fixed dict-lookup cost on top of `resolve_provider` — 21 ns on C1, 17 ns on C2 and 23 ns on C3 in the cells below, close to the same absolute cost each time and 8%, 10% and 3% of the respective baselines. That surcharge was 54-65 ns until 3.3.0 inlined `resolve_provider`'s body into `resolve`, removing a Python frame from the by-type path; what is left is close to the bare dict lookup. C4 does not split this way: modern-di's C4 body resolves **by reference** throughout, while dishka and wireup can only be measured by type. That asymmetry cuts **against** modern-di's C4 ratios, not for them — levelling it would add the ~21 ns by-type lookup to modern-di's cell and move the dishka ratio below from 1.22 to about 1.23. The C1-C3 leveling does not apply to C4. C6 does not split either, for the same reason: modern-di's C6 body resolves by reference and there is no by-type C6 variant to pair against dishka and wireup, so a split would leave that half of the table mixed-basis. The rivals themselves do line up with the C1-C3 grouping here — that-depends resolves its C6 handler by reference exactly as it does on C1-C3 — so it is modern-di's missing variant, not the rivals' idioms, that keeps C6 in one table against all four. Every published cell is timed at the same `rounds × iterations` for every framework (C1-C3 and C6 at 200 × 1000, C4 at 100 × 3), so no cell carries per-round timer overhead or sits on the platform timer's ~42 ns grid while the cell it is divided by does not. See [`benchmarks/README.md`](https://github.com/modern-python/modern-di/blob/main/benchmarks/README.md) for why that matters and which cells it moved. ## Results Measured 2026-08-03 with modern-di 3.3.0 on an Apple M4 (macOS 26.5), CPython 3.14.6, median over 5 runs (ratios paired within each run); the footnote under each table bounds the across-run dispersion of each side's own median. Rival versions: dishka 1.10.1, dependency-injector 4.49.1, that-depends 4.0.2, wireup 2.12.0. Generated by `just bench-report`. > **These numbers are a snapshot of the version named above; a newer release does not update them.** The tables are regenerated by hand, so they lag a release rather than ship with one. If you are running a later modern-di, nothing below has been re-measured against it: run `just bench-report` yourself (see [Reproduce it yourself](#reproduce-it-yourself)) to measure the version you have. Each cell is modern-di ÷ rival: below 1.0 (bold) means modern-di is faster, above 1.0 means slower. Every ratio is **paired within each run** — one run measures both sides under the same machine state, so the published statistic is the median of the per-run ratios, not a ratio of two independently-reduced medians. Pairing gives each ratio a well-defined across-run IQR, published as the `±X.X%` on the cell; read it before treating a near-1.00 cell as a verdict. ### By-reference resolution | Scenario | modern-di | vs dependency-injector | vs that-depends | | ----------------- | ------------ | ---------------------- | --------------- | | C1 transient | 252 ns ±1.7% | **0.53** ±1.2% | **0.65** ±1.2% | | C2 warm singleton | 157 ns ±0.3% | 2.64 ±0.6% | 1.90 ±0.6% | | C3 deep chain (6) | 706 ns ±0.5% | **0.38** ±0.8% | **0.53** ±0.1% | *Across-run IQR of each side's own median (5 runs): modern-di ≤1.7%, rivals ≤0.9%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.* ### By-type resolution | Scenario | modern-di | vs dishka | vs wireup | | ----------------- | ------------ | -------------- | -------------- | | C1 transient | 273 ns ±0.2% | **0.91** ±0.7% | 1.01 ±0.4% | | C2 warm singleton | 174 ns ±0.5% | **0.81** ±0.5% | 1.84 ±2.4% | | C3 deep chain (6) | 729 ns ±0.7% | 1.30 ±0.8% | **0.90** ±1.2% | *Across-run IQR of each side's own median (5 runs): modern-di ≤0.7%, rivals ≤2.2%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.* ### Request lifecycle (batched, published per request) | Scenario | modern-di | vs dependency-injector | vs that-depends | vs dishka | vs wireup | | -------------------- | ------------- | ---------------------- | --------------- | ---------- | -------------- | | C4 request lifecycle | 2.39 µs ±0.8% | **0.02** ±0.3% | **0.19** ±0.3% | 1.22 ±2.2% | **0.13** ±1.2% | *Across-run IQR of each side's own median (5 runs): modern-di ≤0.8%, rivals ≤0.9%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.* ### Per-request context | Scenario | modern-di | vs dependency-injector | vs that-depends | vs dishka | vs wireup | | ---------- | ------------- | ---------------------- | --------------- | ---------- | ---------- | | C6 context | 1.61 µs ±1.9% | **0.53** ±2.0% | **0.58** ±0.5% | 1.47 ±0.9% | 1.23 ±2.0% | *Across-run IQR of each side's own median (5 runs): modern-di ≤1.9%, rivals ≤1.6%. The ± on each ratio cell is a different quantity: the spread of the paired per-run ratios.* ## What the numbers show - Against `dependency-injector`, modern-di is faster by reference on C1 (**0.53**) and C3 (**0.38**), and far faster on the batched C4 request lifecycle (**0.02**). dependency-injector's C4 body calls `init_resources()`/`shutdown_resources()` every cycle in addition to resolving; the suite doesn't decompose how much of its per-request cost is that lifecycle work versus the resolve itself, so C4 should be read as a whole-lifecycle comparison, not an isolated resolve (see the caveat below). dependency-injector is still faster on C2 warm-singleton (2.64, an implied ~60 ns cache hit against modern-di's 157 ns): its hit is a C-level slot read on a Cython-compiled core, where modern-di's is a Python dict lookup behind an override guard. Pure Python does not reach ~60 ns, so this cell is expected to stay above 1.0 however much of modern-di's own overhead is removed. - Against `that-depends`, modern-di leads by reference on C1 (**0.65**) and C3 (**0.53**). The C1 cell has moved a long way across publications (1.08, 1.12, 0.98, 0.98, 0.97, 0.89, 0.91, now 0.65) and this is the largest step in that series; it is modern-di moving, not that-depends, whose implied C2 absolute is unchanged at ~83 ns. that-depends remains faster on C2 warm-singleton (1.90); the suite does not decompose its `resolve_sync` cache-hit path, so no mechanism is asserted for the remaining gap. - **Against the two `exec`-codegen frameworks, the by-type table has crossed over.** At 3.3.0 modern-di is faster than `dishka` on C1 (**0.91**) and C2 (**0.81**), and faster than `wireup` on C3 (**0.90**) while level on C1 (1.01). One publication earlier it was slower than both on every one of these cells. dishka keeps a clear lead on C3 (1.30), the deepest graph, which is consistent with the per-node call frame that `exec`-inlined source removes and modern-di keeps — the mechanism this page has always asserted for dishka, and the one cell where it still dominates. modern-di does not generate code (a [documented non-goal](https://modern-di.modern-python.org/introduction/design-decisions/#non-goals)); the gap closed by removing frames from the interpreted path instead. - **At 3.3.0 the by-type surcharge is small enough to stop mattering.** Dividing modern-di's by-reference cells by its by-type ones gives a fixed cost of 21/17/23 ns on C1/C2/C3, against 54-65 ns one publication earlier. `Container.resolve` no longer delegates to `resolve_provider` — it carries that body itself — so what remains is close to the bare registry dict lookup. This is why the by-type table moved further than the by-reference one. - On C6 (per-request context) modern-di is faster than `dependency-injector` (**0.53**) and `that-depends` (**0.58**), and slower than `dishka` (1.47) and `wireup` (1.23). The direction matches the by-type table — the two codegen frameworks lead, the two others trail — but the cells are **not** on one basis: each framework supplies the request value through its own idiom, and two of those are structural analogs rather than equivalents (see the caveat below). No mechanism is asserted for the gaps; the suite does not decompose any framework's context lookup. - On C4 (request lifecycle), the corrected batching does not *remove* the ~35 µs asyncio floor — it amortizes it. The guard tier's `test_g7c_event_loop_floor_control` times the same batch shape with an empty body and puts the residual at **~0.35 µs per request** still inside every C4 cell (~15% of modern-di's C4 figure), shared identically by all five frameworks. Before batching, that floor was ~93% of every cell and compressed the real differences toward 1.0; the page used to read modern-di as "level with dishka (1.00)" on that basis. With the floor amortized, dishka is measurably **faster** than modern-di here (1.22 — modern-di is the slower side of that cell), not tied. modern-di remains far faster than that-depends, dependency-injector, and wireup on this scenario. **What moved in this publication, and why the attribution is unusually clean.** This is the second publication of the day, on the same machine, the same macOS 26.5 and CPython 3.14.6, and the same four pinned rival versions — a few hours apart. **Every rival's implied absolute is unchanged**: dependency-injector's C2 hit 59.7 → 59.5 ns, that-depends' 82.6 → 82.6, dishka's 215.3 → 214.8, wireup's 95.0 → 94.6. Nothing drifted, so every cell that moved is modern-di's 3.3.0. | | 3.2.0 | 3.3.0 | | | ----------------------------------- | ---------- | ----------- | ------------- | | C1 transient, by reference | 353 ns | **252 ns** | −28.6% | | C3 deep chain, by reference | 965 ns | **706 ns** | −26.8% | | C1 transient, by type | 413 ns | **273 ns** | −33.9% | | C2 warm singleton, by type | 211 ns | **174 ns** | −17.5% | | C3 deep chain, by type | 1.03 µs | **729 ns** | −29.2% | | C6 context | 1.68 µs | **1.61 µs** | −4.2% | | **C2 warm singleton, by reference** | **157 ns** | **157 ns** | **unchanged** | That last row is the control, and it is unchanged *by construction*: 3.3.0's two largest changes are the arity-specialised creator call, which is on the cold-miss path a warm cached hit returns before reaching, and the by-type inline, which a by-reference resolve never enters. A warm by-reference cache hit touches neither. It was predicted to be flat before the run and it was. The wins themselves: a factory with 0 or 1 provider dependencies now compiles to a closure that names its argument and calls the creator directly, rather than building a list and star-calling it (C1 and C3, whose nodes are arity 1); `Container.resolve` carries its own copy of `resolve_provider`'s body (the whole by-type column); and a context-backed parameter has its binding folded into the compiled closure (C6). **The C4 gain recorded at 3.1.1 was a library fix**, and it stands: every `Container` used to store itself in its own `_scope_map`, making it a reference cycle that reference counting could never free, so a request-scoped application handed the garbage collector work at its request rate. Seeding the map from the parent instead removed the cycle. Measured on the C4 benchmark at the time, that cut the median from 232.7 µs to 194.8 µs per 100-request batch and the standard deviation from 123.0 µs to 7.9 µs — the tail this scenario used to carry was the collector reclaiming containers, and it is gone. **C4 is a batched request lifecycle.** modern-di resolves the connection synchronously while finalizing it asynchronously; the other four force an awaited resolve once the finalizer is async. C4 therefore measures the whole request lifecycle (enter scope → resolve → async-finalize), not an isolated resolve. It is timed as a **batch of 100 cycles per event-loop entry**, because a single `run_until_complete` entry costs ~35 µs on any body — timing one request per entry made every framework's cell ~93% asyncio floor. The published figure is the batch divided by 100. C1–C3 are synchronous resolves for every framework. **C6 is sync for all five, but not one idiom.** Each framework supplies the per-request value its own way: modern-di seeds a child container's context and resolves by reference; dishka uses `from_context`; wireup requires the runtime type registered as a scoped injectable behind a raising placeholder factory; that-depends supplies it through `container_context(global_context=)`; and dependency-injector injects **by reference** via `providers.Dependency` + `.override()`, a structural analog rather than an equivalent. modern-di's timed body builds the child, resolves, and closes it. It calls no `open()` — a freshly built child is already open as of 3.1, so timing one would charge modern-di a redundant lock acquire (81 ns, ~6% of the cell) with no counterpart in any rival's body. It does close, because all four rivals exit their scope inside the timed body; that teardown is ~110 ns, and omitting it would have flattered modern-di by more than the `open()` would have cost it. **Thread-safety configuration differs, at each framework's default.** dishka's `make_container` defaults to `lock_factory=`, so every `get()` behind its C1–C3 cells acquires a lock; modern-di's cached read is lock-free by design (see [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/#the-thread-safety-boundary)). Both run at their defaults, which is the comparison a user gets out of the box — a dishka user targeting single-threaded work can pass `lock_factory=None`, and that would move dishka's C1–C3 cells. The axis is disclosed rather than normalized away. ## Why the results look this way Since 2.29.0, modern-di compiles one specialized closure per provider on first resolve, memoized on the providers registry, replacing a generic per-call interpreted resolver. Each compiled resolver hoists its scope navigation, override check, and cache lookup out of the per-call path and calls its dependencies' resolvers directly. Against dishka the remaining gap widens with graph depth, consistent with the per-node call frame that `exec`-inlined source removes and modern-di keeps; wireup's gap narrows with depth instead, so the same explanation is not claimed for it (see the by-type discussion above). 3.1.0 removed one more frame from the top of every resolve: `resolve_provider` now opens with an inline `closed` check instead of an unconditional method call, and `build_child_container` carries no such check at all. Measured on the guard suite against 3.0.0 on one machine, that is worth roughly 5–11% on C1- and C2-shaped resolves and on child construction; the deeper scenarios, which run through compiled resolvers where the check was already inline, did not move. 3.1.1 removed a reference cycle rather than a frame: every `Container` stored itself in its own `_scope_map`, so no container could be freed by reference counting and each one waited for the garbage collector. Seeding the map from the parent removed it. Resolution is untouched — the C1–C3 cells did not move — but the request lifecycle did: C4's median fell from 232.7 µs to 194.8 µs per 100-request batch and its standard deviation from 123.0 µs to 7.9 µs, because the collector no longer has to reclaim containers that refcounting now frees. 3.1.2 removed two more frames, this time from the warm-hit path. A cached resolve reached its compiled resolver through `ProvidersRegistry.resolver_for` and its `CacheItem` through `CacheRegistry.fetch_cache_item`; both methods open with a dict lookup that hits and returns. Both lookups are now inlined at the call site, with the method called only on a miss — where it still owns the cycle guard, the memo write, and the `setdefault` that makes concurrent first-resolvers share one `CacheItem`. Worth ~42 ns on a warm hit, and because the first sits in `resolve_provider` it applies to every top-level resolve rather than only cached ones. 3.2.0 trimmed three more paths rather than one. The cached resolver's cold-miss thunk is now built with `functools.partial` instead of a lambda closing over the target container: a closure promotes that variable to a cell for the *whole* resolver, so `MAKE_CELL` ran in the prologue on every call — including the warm hit that returns two lines later and the override hit that never reaches it (−11.3% on a warm hit). The context-kwarg path front-guards its override lookup on `has_overrides` (−6.0%), which is the path every framework integration takes for its per-request values. And an `Alias` stopped routing through `Alias._find_source` and `Container.resolve_provider` on every hop: it now inlines both lookups and calls its source's compiled resolver directly, one Python frame per hop instead of four (~322 → ~252 ns). The alias change has no cell on this page — there is no alias scenario in the comparative suite. 3.3.0 attacked the *call*, not the lookups. `resolve_positional` built its arguments with a list comprehension and star-called the creator, though the dependency count is fixed the moment a resolver compiles; a factory with 0 or 1 provider dependencies now compiles to a closure that names its argument and calls the creator directly — no list, no `CALL_FUNCTION_EX`, and below 3.12 no comprehension frame either. The ladder stops at 1 because that is where the measured win is (leaves are arity 0, chain nodes are arity 1); rungs beyond it were built, measured, and dropped. Separately, `Container.resolve` stopped delegating to `resolve_provider` and carries that body itself, which is what shrank the by-type surcharge from 54-65 ns to 21/17/23 ns, and a context-backed parameter had its binding folded into the compiled closure. Together those are worth −27 to −34% across C1, C3 and the whole by-type column, against rivals whose absolutes did not move between the two publications. ## Reproduce it yourself ```bash git clone https://github.com/modern-python/modern-di cd modern-di just bench-report # isolated env; first run resolves the pinned rival deps; runs 5x by default ``` `just bench-report` also prints a C5 (cold build + first resolve) scenario that this page does not publish: its cells are not one axis — dependency-injector's is ~98% provider-graph deepcopy and that-depends wires at import with no per-container build at all — so a ratio column would assert a comparison those numbers cannot support. See [`benchmarks/README.md`](https://github.com/modern-python/modern-di/blob/main/benchmarks/README.md). The comparative environment is isolated and its result files are not committed, so absolute numbers will differ from those above. The ratios are more comparable across machines than the absolute times. ## See also - [Comparison](https://modern-di.modern-python.org/introduction/comparison/index.md) — how modern-di compares on features. - [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/index.md) — why resolution is sync-only and why `exec` codegen is a non-goal. # modern-di for FastAPI users FastAPI's own `Depends` system covers a single request-scoped web service well. You reach for modern-di once you need a second entrypoint (a worker, a CLI), typed app-wide singletons with real teardown, or overrides that work outside the HTTP path — see [Do you even need a DI container?](https://modern-di.modern-python.org/introduction/comparison/#do-you-even-need-a-di-container). This page translates the `Depends` idioms you already know into their modern-di equivalents. ## Translation table | FastAPI `Depends` | modern-di | Notes | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Depends(fn)` | `Factory(fn)` | Both auto-wire the callable's parameters; modern-di matches by type annotation instead of by the callable's own parameter defaults. | | bare `Depends(fn)` (`use_cache=True`, the default) | `Factory(fn, scope=Scope.REQUEST, cache=True)` | FastAPI memoizes a dependency for the rest of the *same request* once it's been called; the REQUEST-scoped cached `Factory` is the equivalent — one shared instance per request container. | | `Depends(fn, use_cache=False)` | a bare `Factory(fn)` — no `cache` | Without `cache`, a `Factory` builds a fresh instance on every resolve, matching `use_cache=False`. | | `yield`-based teardown (`def fn(): ...; yield x; ...cleanup...`) | `cache=CacheSettings(finalizer=cleanup_fn)` | modern-di has no generator-creator form (see [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/index.md)); teardown is a second, explicit object instead of code after `yield`. `finalizer` may be sync or async — see [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md). | | `@lru_cache`-wrapped dependency (process-wide singleton) | `Factory(fn, scope=Scope.APP, cache=True)`, optionally with a `finalizer` | `lru_cache` has no cleanup hook; the APP-scoped cached `Factory` adds one via `CacheSettings(finalizer=...)` if the singleton needs to release anything on shutdown. | | `app.dependency_overrides[fn] = fake` | `container.override(provider, fake)` | modern-di overrides are keyed by **provider reference**, not by callable, and apply across the whole container tree — see [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md). Reset with `container.reset_override(provider)`. | | the manual `try`/`finally` reset FastAPI's docs recommend around `dependency_overrides` | `with container.override(provider, fake) as mock: ...` | Auto-resets on exit instead of a hand-written `finally`. See [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) for the full semantics. | ## Two meanings of "scope" Since FastAPI 0.121.0, `Depends(scope="function" | "request")` controls **when the code after `yield` runs** relative to the response: `scope="function"` tears down right after your path operation function returns (before the response is sent), and `scope="request"` — the default for a `yield` dependency — tears down after the response has been sent back to the client. It says nothing about how many times the dependency is *constructed*; that's `use_cache`'s job. modern-di's `Scope` (`APP → SESSION → REQUEST → ACTION → STEP`) answers a different question entirely: **how long a provider's cached instance lives**, not when its finalizer fires relative to a response. The two `scope`s share a word but not an axis — FastAPI's is teardown timing, modern-di's is lifetime. See [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) for the full model. ## Example: request-scoped session with teardown ```python import dataclasses from modern_di import Group, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True) class Session: connection_string: str def create_session() -> Session: return Session(connection_string="postgresql+asyncpg://localhost/app") def close_session(session: Session) -> None: ... # release the connection class Dependencies(Group): session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) ``` This is the modern-di equivalent of a FastAPI `yield`-dependency that hands out one session per request and closes it afterward — but with the container's finalizer, not code after `yield`, and `Scope.REQUEST` naming the lifetime rather than the teardown moment. ## See also - [modern-di vs other libraries](https://modern-di.modern-python.org/introduction/comparison/index.md) — including the cross-framework vocabulary table. - [FastAPI integration](https://modern-di.modern-python.org/integrations/fastapi/index.md) — `setup_di`, `FromDI`, and websocket scopes. - [Design decisions](https://modern-di.modern-python.org/introduction/design-decisions/index.md) — why modern-di has no generator-based teardown. # Providers # Scopes A scope is the lifetime band that a provider lives in. `modern-di` has five built-in scopes, ordered from longest-lived to shortest: ```text APP → SESSION → REQUEST → ACTION → STEP ``` `Scope` is an `IntEnum` — `APP=1`, `SESSION=2`, `REQUEST=3`, `ACTION=4`, `STEP=5`. The higher the int, the shorter the lifetime. ## What each scope is for | Scope | Typical use | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `APP` | One-per-process resources: settings, the database engine, a Redis client, a Kafka producer. The default if you omit `scope=`. | | `SESSION` | One-per-websocket-connection resources. Framework integrations enter SESSION automatically when a websocket opens. | | `REQUEST` | One-per-HTTP-request resources: the database session, the per-request user repository, the current `Request` object. Framework integrations create the REQUEST child container for each incoming request. | | `ACTION` | A sub-step inside a request — e.g. one item in a batch handler that should get its own cached values. Enter manually with `build_child_container`. | | `STEP` | A sub-step inside an ACTION. Same idea, one level deeper. | `APP` and `REQUEST` cover the vast majority of real apps. Reach for `SESSION` only for websockets; `ACTION`/`STEP` are for cases where you want isolated caching inside a request. ## The container tree The root `Container` is at `APP` scope. Child containers are built from a parent via `build_child_container(scope=...)`, where the child's scope must be *higher* (shorter-lived) than the parent's. ```python from modern_di import Container, Scope app_container = Container(groups=[Dependencies]) # APP scope with app_container.build_child_container(scope=Scope.REQUEST) as request_container: ... ``` `Dependencies` here is a `Group` subclass holding the provider definitions — see the [Quick Start](https://modern-di.modern-python.org/index.md) or [Resolving dependencies](https://modern-di.modern-python.org/introduction/resolving/index.md) for how it's declared. Children share their parent's `providers_registry` (provider definitions) and `overrides_registry` (test overrides) but have their own `cache_registry` (resolved instances) and `context_registry` (runtime context values). That's why a REQUEST-scoped factory produces one instance per request — the cache lives on the request container, not the app container. ## The scope dependency rule **A provider can only depend on providers at the same scope or a broader (lower int) scope.** A REQUEST-scoped session can consume the APP-scoped engine. The engine cannot consume the session. Why: lifetime safety. If an APP-scoped singleton held a reference to a REQUEST-scoped session, the session would outlive its request and produce stale state — this is called a **captive dependency**: a wide-scoped (long-lived) provider "captive" to a narrower-scoped (shorter-lived) one it cannot actually hold onto. `container.validate()` enforces this — call it at startup. See [Good and bad practices](https://modern-di.modern-python.org/recipes/good-and-bad-practices/#1-captive-dependency-a-wide-scoped-provider-holding-a-narrow-scoped-one) for a worked example of the mistake and the fix. ### How to choose a scope A provider's scope should be the **maximum** scope value among all its dependencies (i.e. the shortest-lived one). Examples: - A provider depends on an APP-scoped engine and a REQUEST-scoped session → REQUEST. - A provider has no dependencies → APP (the default). - A provider depends only on APP-scoped providers → APP. If you pick a broader scope than the rule allows, `container.validate()` catches it at startup. ## Building child containers Two patterns: **Manual.** Use the child container as a context manager so finalizers run on exit: ```python with app_container.build_child_container(scope=Scope.REQUEST) as request_container: service = request_container.resolve(UserService) # finalizers ran here async with app_container.build_child_container(scope=Scope.REQUEST) as request_container: service = request_container.resolve(UserService) # async finalizers ran here ``` Use `async with` only when the scope holds providers with async finalizers; otherwise plain `with` is enough. Resolution itself is always synchronous. **Framework-managed.** The [framework integrations](https://modern-di.modern-python.org/integrations/fastapi/index.md) build the per-request child container for each request (or per-message for brokers) and tear it down at the end. You only declare `scope=Scope.REQUEST` on the providers that need it. ## Resolving across scopes Resolution looks up each parameter's type in the providers registry, finds the container at that provider's declared scope, and resolves from there. If you resolve an APP-scoped provider from a REQUEST container, you transparently walk up to the APP container — the cached APP instance is returned. ```python # REQUEST container can resolve APP-scoped providers engine: AsyncEngine = request_container.resolve(AsyncEngine) # walks up to APP session: AsyncSession = request_container.resolve(AsyncSession) # local to REQUEST ``` Trying to resolve a REQUEST-scoped provider from an APP container raises [`ScopeNotInitializedError`](https://modern-di.modern-python.org/providers/errors-and-exceptions/index.md) — the request container hasn't been built yet, so there's nothing to resolve into. ## Custom scopes For non-standard lifecycles (per-tenant containers, background-job runs, anything that doesn't fit the built-in five), pass any `IntEnum` value where `Scope` is accepted: ```python from enum import IntEnum from modern_di import Container, Group, providers class MyScope(IntEnum): TENANT = 6 BACKGROUND_JOB = 7 class TenantContext: pass class MyGroup(Group): tenant_provider = providers.Factory(TenantContext, scope=MyScope.TENANT) container = Container(groups=[MyGroup]) with container.build_child_container(scope=MyScope.TENANT) as tenant_container: tenant = tenant_container.resolve(TenantContext) ``` The child scope's integer value must be strictly greater than its parent's. When `scope=` is omitted from `build_child_container`, the auto-derived next scope only advances within the parent's own enum class — to cross enum boundaries (e.g. jump from a built-in `Scope` to `MyScope.TENANT`), pass `scope=` explicitly. ## Group-level default scope When declaring providers in a `Group` subclass, you can assign a default scope to all members using the class kwarg: ```python from modern_di import Container, Group, Scope, providers class UserRepository: pass class AuditLog: pass class RequestGroup(Group, scope=Scope.REQUEST): repo = providers.Factory(UserRepository) # inherits group default: REQUEST audit = providers.Factory(AuditLog, scope=Scope.APP) # explicit scope wins app_container = Container(groups=[RequestGroup]) with app_container.build_child_container(scope=Scope.REQUEST) as request_container: repo = request_container.resolve(UserRepository) ``` Scope resolution follows a priority order: 1. **Explicit `scope=` on the provider** — always wins 1. **The group's `scope=` kwarg** — inherited via MRO by subclasses; subclasses may override with their own `scope=` kwarg. A subclass's `scope=` applies to providers declared in its own body; inherited providers keep the scope their declaring class gave them. 1. **`Scope.APP`** — the final default `Alias` providers do not participate in group-level scope defaults — an alias's scope always derives from its source. A scope-defaulted provider instance that is shared between two `Group` subclasses with different defaults raises [`GroupScopeConflictError`](https://modern-di.modern-python.org/troubleshooting/group-scope-conflict-error/index.md) at class-creation time. Sharing the same provider instance with the same default scope across multiple groups is allowed. A group declared without a `scope=` kwarg stamps nothing, so a provider listed only in such a group keeps the `Scope.APP` default and can still be stamped by a later group — but only until it is registered with a container. After that, a group that would *change* its scope raises [`ProviderScopeFrozenError`](https://modern-di.modern-python.org/troubleshooting/provider-scope-frozen-error/index.md), because resolvers compiled before the change already captured the old scope. Declare every group that lists a provider before building the container, or set `scope=` on the provider explicitly. ## See also - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()` work per-scope. - [Container Provider](https://modern-di.modern-python.org/providers/container/index.md) — injecting the active container into a creator. - [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) — pattern for APP-scoped async setup. # Lifecycle How instances are created, cached, and cleaned up. The code blocks below assume the following import, and `Dependencies` is a user-defined `Group`: ```python from modern_di import Container, Scope, providers, exceptions ``` ## Lazy initialization `modern-di` creates instances on first resolve. There is no `init_resources()` or "eager startup" call — if a provider is never resolved, its creator never runs. If you want a provider warmed up at startup (e.g. eager-connect the database engine), call `container.resolve(SomeType)` for it in your application's startup hook. ```python container = Container(groups=[Dependencies]) # Warm caches at startup container.resolve(AsyncEngine) container.resolve(Settings) ``` ## Caching and finalizers `CacheSettings` controls two things: whether resolved instances are cached, and what to do when they're cleaned up. ```python session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) ``` - **Caching.** With `cache=True`, the provider returns the same instance for every resolve inside that scope's container — this is the singleton idiom, see [Cached factories](https://modern-di.modern-python.org/providers/factories/#cached-factories). Without `cache`, the provider creates a fresh instance every call. - **Finalizer.** A callable that runs on the cached instance when the container is closed. Sync or async — `CacheSettings` auto-detects via `inspect.iscoroutinefunction()`. The finalizer takes one argument: the cached instance. ```python def close_engine_sync(engine: Engine) -> None: engine.dispose() async def close_engine_async(engine: AsyncEngine) -> None: await engine.dispose() ``` Both work — pick whichever matches the resource. ## Closing the container Three ways to run finalizers: ```python # Sync container.close_sync() # Async await container.close_async() # Context manager (preferred — cleanup runs even on exceptions) with container: ... async with container: ... ``` Closing a container runs its finalizers in reverse-creation order (creation order equals first-resolve order, since creation is lazy), then clears the cache. ## Close-failure semantics Closing keeps going when a finalizer fails — it never stops at the first error. **A finalizer that raises does not abort the others.** Every finalizer runs; the exceptions are collected and re-raised together as a single `FinalizerError` once cleanup finishes. Its `.finalizer_errors` attribute holds the list of underlying exceptions, and `.is_async` records whether `close_sync()` or `close_async()` raised it. So a broken finalizer can't leak a resource that a later finalizer would have closed. **Calling `close_sync()` on a cached resource with an async finalizer is recoverable.** `close_sync()` cannot await, so when it reaches such a resource it produces an `AsyncFinalizerInSyncCloseError` — delivered *wrapped inside* the aggregated `FinalizerError` (as an entry in `.finalizer_errors`), since sync close aggregates like any other failure. Crucially, the resource's cache entry is **retained** rather than discarded, so the resource is not lost: a later `await container.close_async()` finalizes it correctly and completes the cleanup. ```python # Resource with an async finalizer, resolved into the cache. container.resolve(AsyncResource) try: container.close_sync() except exceptions.FinalizerError as exc: # exc.finalizer_errors contains an AsyncFinalizerInSyncCloseError; # the cache was kept, nothing was finalized yet. ... await container.close_async() # recovers — runs the async finalizer now ``` Prefer `async with container:` (or `await close_async()`) whenever any provider has an async finalizer; the sync path is only a safety net. ## Closing and reopening A constructed container is **open from construction** — `closed = False` the moment `Container(...)` returns, with no `open()` step required before the first `resolve()` / `resolve_provider()` call. `build_child_container()` never checks or touches any container's open/closed state — it only reads the parent's shared registries and scope map — and the returned child starts open too, same as any fresh container. `close_sync()` / `close_async()` run the finalizers (in reverse-creation order, as above) and mark the container closed; entering `with container:` (or `async with`) is the idiomatic way to guarantee that close runs, even on an exception. Resolving from a container **that was explicitly closed** — directly, or through a child whose resolve reaches back into that container's scope — reopens it and emits `ContainerClosedWarning` — a signal that a reference to the container is being held past its lifetime, unless the reuse is deliberate. Building a child of a closed container does not, by itself, trigger any of this. Re-entering `with container:` (or calling `open()` directly) reopens it silently instead, since a deliberate reopen isn't diagnostic-worthy: ```python container = Container(groups=[Dependencies]) with container: container.resolve(Settings) # closed here — finalizers ran container.resolve(Settings) # warns ContainerClosedWarning, then reopens and resolves with container: # reopened silently — no warning container.resolve(Settings) ``` See [Troubleshooting: ContainerClosedError](https://modern-di.modern-python.org/troubleshooting/container-closed-error/index.md) for what `ContainerClosedWarning` means and how to respond to it, and [Migration: To 3.x](https://modern-di.modern-python.org/migration/to-3.x/#1-closed-containers-raise-instead-of-self-healing) for how this differed in 3.0. How a cached instance survives this cycle depends on its `CacheSettings`: - With the default `clear_cache=True`, the instance is finalized at close and rebuilt on the next resolve after reopen. - With `clear_cache=False`, the cached instance survives close→reopen and is returned again — the *same object* (its finalizer runs once, at the first close, and is not re-run on later closes). Use this for a shared resource whose identity must stay stable across restarts. - Overrides are not part of this survival — closing a root container resets its overrides registry, and reopening (via `with`/`open()`) does not restore overrides set beforehand; only cached instances (with `clear_cache=False`) survive close→reopen. The context manager is not reference-counted Nesting `with container:` on the **same** object closes it on the inner `with` exit, not the outer one. Use one `with` block per container, or build a child container for the inner scope. ## Per-scope finalization Each container has its own finalizers — the ones for the providers it cached. When a child container exits its `with` block, only the child's finalizers run; the parent's stay alive for as long as the parent does. ```python app_container = Container(groups=[Dependencies]) app_container.validate() # optional: fails fast here instead of at whichever resolve hits a problem first async with app_container.build_child_container(scope=Scope.REQUEST) as request_container: session = request_container.resolve(AsyncSession) # work... # request_container's REQUEST-scope finalizers ran (e.g. session.close()) # app_container's APP-scope finalizers DID NOT run await app_container.close_async() # now app_container's finalizers run (e.g. engine.dispose()) ``` Framework integrations handle this automatically: they build the REQUEST child container per request and exit its context at the end of the request, then call `close_async()` on the APP container at app shutdown. ## Validation `container.validate()` is the only thing that walks the graph. Nothing validates automatically — not construction, not `open()`, not `add_providers`, not `resolve()`. A container is fully usable, and stays usable, without ever calling `validate()`; a broken graph nobody validates simply surfaces at whichever resolve first hits the problem, as an ordinary resolution error. Call it explicitly, whenever you want the whole graph checked at once — cycles, inverted scope dependencies, and missing required dependencies, all in a single pass: ```python container = Container(groups=[Dependencies]) container.validate() # walks now; raises ValidationFailedError if any issue is found ``` It aggregates every issue it finds into one `exceptions.ValidationFailedError` rather than stopping at the first — see [Troubleshooting: ValidationFailedError](https://modern-di.modern-python.org/troubleshooting/validation-failed-error/index.md). Call it right after building the container for a construction-time check, or later — e.g. a framework integration that registers its own providers after construction (via `add_providers`) should call it **after** that registration, so the complete graph is what gets checked; see [Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/#lifecycle-rules). A repeat `validate()` after a clean walk is free — it memoizes against the registry's contents and only re-walks once something has changed it (`register`/`add_providers`). Validation has no runtime cost after that. Turn it on in a startup path or a single test — it catches the bugs you don't want to discover under load. ### The deprecated `validate` constructor argument `Container(validate=...)` still exists for backward compatibility. Passing `True` or `False` is ignored and emits `exceptions.ValidateArgumentWarning` (a `DeprecationWarning`); omitting it (the default) is silent either way. It changes nothing about the container built — there is no longer a spelling of the constructor that validates for you. The argument is removed in 4.0; call `container.validate()` instead. See [Migration: To 3.x](https://modern-di.modern-python.org/migration/to-3.x/#4-validate-runs-at-container-entry-on-by-default) for how this used to work. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — child containers and per-scope finalization. - [Factories](https://modern-di.modern-python.org/providers/factories/index.md) — `CacheSettings` is configured on the factory itself. - [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) — sync creator + async finalizer is the most common shape. # Factories Factories are providers that create instances of dependencies. ## Types of factories There are two types of factories: **regular** and **cached**. ### Regular Factories Regular factories create a new instance on every call — nothing is cached. ```python import dataclasses from modern_di import Group, Container, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class IndependentFactory: dep1: str dep2: int class Dependencies(Group): independent_factory = providers.Factory( IndependentFactory, scope=Scope.APP, kwargs={"dep1": "text", "dep2": 123} ) container = Container(groups=[Dependencies]) # Resolve by provider reference instance = container.resolve_provider(Dependencies.independent_factory) assert isinstance(instance, IndependentFactory) # Resolve by type (uses the return type of the creator function/class) instance2 = container.resolve(IndependentFactory) assert isinstance(instance2, IndependentFactory) ``` ### Cached Factories Cached factories resolve the dependency only once and cache the resolved instance for future injections. **This is modern-di's Singleton.** There is no separate `Singleton` provider class — `Factory(cache=True)` *is* the singleton idiom, at whatever scope you declare it (`Scope.APP` for one-per-process, `Scope.REQUEST` for one-per-request, etc.). Other DI frameworks name this concept `Singleton`, `provide(..., scope=...)`, `@injectable(lifetime="singleton")`, or `@lru_cache`; see [Where is Singleton?](https://modern-di.modern-python.org/introduction/comparison/#where-is-singleton-cross-framework-vocabulary) for the full cross-framework mapping. The caching mechanism is thread-safe by default, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created. If your application is single-threaded, you can disable the lock for a small performance gain: ```python container = Container(groups=[Dependencies], use_lock=False) ``` Do not set `use_lock=False` in multi-threaded applications — it removes the guarantee that only one instance is created per cached factory. ```python import random from modern_di import Group, Container, Scope, providers def generate_random_number() -> float: return random.random() class Dependencies(Group): singleton = providers.Factory( generate_random_number, scope=Scope.APP, cache=True ) container = Container(groups=[Dependencies]) singleton_instance1 = container.resolve_provider(Dependencies.singleton) singleton_instance2 = container.resolve_provider(Dependencies.singleton) # If resolved in the same container, the instance will be the same assert singleton_instance1 is singleton_instance2 ``` #### Tuning the cache You can customize caching behavior by passing a `CacheSettings` to `cache=`: ```python import contextlib from modern_di import Group, Scope, providers class SomeResource: def close(self) -> None: ... def create_resource() -> SomeResource: # Create and return resource return SomeResource() class Dependencies(Group): # Cache with cleanup — clear_cache=True (the default) ensures the closed # resource is evicted from cache so it cannot be returned again after close resource = providers.Factory( create_resource, scope=Scope.APP, cache=providers.CacheSettings( finalizer=lambda res: res.close(), # Cleanup function ) ) ``` ## Parameters `Factory(creator, *, scope=Scope.APP, bound_type=UNSET, kwargs=None, cache=None, skip_creator_parsing=False)` — `creator` may also be passed as a keyword (`creator=`). When creating a Factory provider, you can configure several parameters: ### scope Defines the lifetime (scope) of the dependency. Defaults to `Scope.APP`. The available scopes are `APP → SESSION → REQUEST → ACTION → STEP`; see [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) for the full mental model and the dependency rule. Groups can declare a default scope for all their members — see [Group-level default scope](https://modern-di.modern-python.org/providers/scopes/#group-level-default-scope). ### creator The callable (function or class) that will be invoked to create instances of the dependency. Modern-DI analyzes the creator's signature to: 1. Determine the return type (used for `bound_type` if not explicitly set) 1. Identify parameter names and types for automatic dependency resolution ### bound_type Explicitly sets the type for resolving by type. By default, this is automatically inferred from the creator's return type annotation. Set to `None` to make the provider unresolvable by type. ### kwargs Manual values for creator parameters that override automatic dependency resolution. Use this to provide specific values for parameters or override automatically resolved dependencies. ### cache Enables caching for the provider. Pass `cache=True` to cache with default settings (no finalizer, cache cleared on close), or `cache=providers.CacheSettings(...)` to tune the finalizer and/or `clear_cache` behavior. Absent, `None`, or `False` means a fresh instance is created on every resolve. See [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) for how caching, finalizers, and `close_async()` fit together. ### skip_creator_parsing Disables automatic dependency resolution. When `True`: - No automatic dependency resolution occurs - All parameters must be provided via the `kwargs` parameter - The `bound_type` will not be automatically inferred from the creator's return type; unless `bound_type` is explicitly provided, it defaults to `None` ## Resolution behavior ### Union type parameters When a parameter is annotated with a union type (e.g. `dep: A | B`), Modern-DI resolves the **first registered type** that matches. The order is determined by how types appear in the union left-to-right. If you rely on a specific type being injected, prefer a concrete type annotation over a union. ### Optional parameters When a parameter is annotated as `X | None` (or `Optional[X]`), the parameter is treated as optional: - If a provider for `X` is registered, that provider is resolved and injected as usual. - If no provider for `X` is registered and the parameter has no default, `None` is injected — no error is raised, and `container.validate()` will not flag the parameter. This also applies to multi-member optional unions (`A | B | None`): the first registered member is injected, otherwise `None`. Trade-off This is a convenience, but it removes a safety net: if you *intended* to register a provider for an optional dependency and forgot, neither `resolve()` nor `validate()` will report it — the parameter silently receives `None`. For dependencies that must always be present, prefer a non-optional annotation (`dep: X`), which raises `ArgumentResolutionError` when unregistered and is flagged by `validate()`. ```python import dataclasses from modern_di import Group, Container, Scope, providers class Cache: ... @dataclasses.dataclass class Service: cache: Cache | None # injected if a Cache provider exists, else None class Dependencies(Group): service = providers.Factory(Service, scope=Scope.APP) container = Container(groups=[Dependencies]) service = container.resolve(Service) assert service.cache is None # no Cache provider registered -> None injected ``` ### Creator-signature support matrix The table below summarises how Modern-DI handles each parameter shape during **declaration** (when the `Factory` object is constructed) and **resolution** (when `container.resolve` is called). "Escapes" means the parameter is silently excluded from automatic wiring and must be covered by `kwargs` or a default. | Parameter shape | Behaviour | When it fails | | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `param: SomeClass` — plain type annotation with a registered provider | Resolved and injected automatically. | `ArgumentResolutionError` at resolve if no provider is registered and there is no default. | | \`param: X | None`/`Optional[X]\` | Provider injected if one is registered; otherwise `None`. | | \`param: A | B`— union without`None\` | First registered type from the union is injected. A member that is itself a parameterized generic (e.g. \`int | | `param: list[X]` / any parameterized generic, **outside a union** | **`UnsupportedCreatorParameterError` at declaration** unless the parameter has a default value or is covered by `kwargs`. | Raised at `Factory(...)` call time. | | Positional-only param (`def f(x: T, /)`) | **`UnsupportedCreatorParameterError` at declaration** unless the parameter has a default (in which case it is silently skipped). | Raised at `Factory(...)` call time. | | Unannotated param (`def f(x)`) | Parsed but unresolvable by type. | `ArgumentResolutionError` at resolve unless covered by `kwargs`. | | Signature whose hints `get_type_hints` cannot resolve (e.g. a forward reference to an undefined name, or — on Python < 3.14 — `functools.partial`) | `UserWarning` is emitted and type-based wiring is skipped; parameters are still parsed (as unannotated). Silence by passing `skip_creator_parsing=True` and an explicit `bound_type`. | A required unannotated param with no provider/default raises `ArgumentResolutionError` at resolve unless covered by `kwargs` (a parameterized-generic or positional-only param still raises `UnsupportedCreatorParameterError` at declaration). | | `skip_creator_parsing=True` | No wiring at all — every required argument must be supplied via `kwargs`. | `CreatorCallError` at resolve for any missing required argument. | A parameterized generic used *inside* a union (`param: int | list[X]`) is the one exception to the "parameterized generic raises at declaration" row above: the member degrades to its bare origin type like any other union member, so it can match a provider registered for `list`. The element type `X` is not checked in that case — this is intentional, not a wiring guarantee, so don't rely on it to route only correctly-typed collections. **Escaping problem shapes** — if a parameter shape would raise at declaration, there are three escape routes, in order of preference: 1. Give the parameter a default value (`def f(items: list[X] | None = None)`). 1. Supply the value via `kwargs={"items": []}` at `Factory` declaration time. 1. Pass `skip_creator_parsing=True` (and supply all required args via `kwargs`). ### Provider passed as a kwargs value Passing an `AbstractProvider` instance directly as a value in the `kwargs` dict is treated as **explicit wiring**: Modern-DI resolves the provider and injects the resolved value — the provider object itself is never seen by the creator. ```python from modern_di import Container, Group, Scope, providers class Backend: pass def make_service(dep: object) -> object: ... class Dependencies(Group): backend = providers.Factory(Backend, scope=Scope.APP) service = providers.Factory( make_service, scope=Scope.APP, skip_creator_parsing=True, bound_type=None, kwargs={"dep": backend}, # provider object — resolved at resolve-time ) container = Container(groups=[Dependencies]) # make_service receives a Backend instance, not the Factory provider ``` This is useful when `skip_creator_parsing=True` is in effect but you still want dependency injection for some arguments rather than hard-coding concrete values. ### Creator-failure semantics If a creator raises an exception during resolution: - **Nothing is cached.** The failed instance is never stored in the cache registry, even if `cache` is set. - **The next `resolve` call retries.** Subsequent resolves call the creator again from scratch, so a transiently-failing creator will eventually succeed once the underlying condition is fixed. - **Already-resolved dependencies are not rolled back.** Dependencies that were successfully resolved before the creator raised are still held in their respective containers and will be finalized normally when those containers are closed. ```python import dataclasses from modern_di import Container, Group, Scope, providers attempt = 0 def flaky_creator() -> object: global attempt attempt += 1 if attempt == 1: raise RuntimeError("transient failure") return object() class Dependencies(Group): svc = providers.Factory( flaky_creator, scope=Scope.APP, cache=True, ) container = Container(groups=[Dependencies]) try: container.resolve(object) except RuntimeError: pass # first call fails — nothing is cached result = container.resolve(object) # retry succeeds assert result is container.resolve(object) # now cached ``` # Context Providers Often, scopes are connected with external events: HTTP requests, messages from a queue, callbacks from a framework. These events can be represented by objects which can be used for dependency creation. `ContextProvider` is a provider type that injects runtime context values — framework objects like requests or websockets, or your own custom context — into dependencies, extracting them from the container's context registry at resolve time. In integrations, some context objects (like `fastapi.Request`, `litestar.WebSocket`, etc.) are automatically provided — see [Framework Context Objects](#framework-context-objects) below. `ContextProvider(context_type, *, scope=Scope.APP, bound_type=UNSET)` — `context_type` may also be passed as a keyword (`context_type=`). ## Basic Usage Declare a `ContextProvider` for your context type, supply the value when you build the child container, and any [`Factory`](https://modern-di.modern-python.org/providers/factories/index.md) that takes that type as a parameter receives it automatically: ```python from modern_di import Group, Container, Scope, providers # Custom context type class CustomContext: def __init__(self, user_id: str, tenant_id: str) -> None: self.user_id = user_id self.tenant_id = tenant_id def create_user_info(custom_context: CustomContext) -> dict[str, str]: return { "user_id": custom_context.user_id, "tenant_id": custom_context.tenant_id, } class Dependencies(Group): # Manually defined ContextProvider for custom context custom_context = providers.ContextProvider(CustomContext, scope=Scope.REQUEST) # Factory uses the custom context user_info = providers.Factory( create_user_info, scope=Scope.REQUEST, ) # Provide custom context when building the child container container = Container(groups=[Dependencies]) custom_context = CustomContext(user_id="123", tenant_id="abc") request_container = container.build_child_container( scope=Scope.REQUEST, context={CustomContext: custom_context} ) # Now resolve the factory — it will receive the custom context automatically user_info = request_container.resolve_provider(Dependencies.user_info) # {"user_id": "123", "tenant_id": "abc"} ``` The provider is bound to a [scope](https://modern-di.modern-python.org/providers/scopes/index.md) (here `Scope.REQUEST`) and the value is supplied via [`build_child_container(context={...})`](https://modern-di.modern-python.org/providers/container/index.md). ## When no value is set A `ContextProvider` reads its value from the context of the container at its bound scope. If nothing was supplied, behavior depends on the call path: - Resolving it **directly** (`container.resolve(CustomContext)`) raises `ContextValueNotSetError` (see [Migration: direct resolve of an unset `ContextProvider` raises](https://modern-di.modern-python.org/migration/to-3.x/#5-direct-resolve-of-an-unset-contextprovider-raises)). - Injecting it into a `Factory` parameter that is **not** `Optional`/defaulted raises `ArgumentResolutionError`. Annotate the consuming parameter as `X | None` (or give it a default) if the value can legitimately be absent. See [ContextProvider has no value](https://modern-di.modern-python.org/troubleshooting/context-not-set/index.md). ## Context propagation Context never propagates between containers. A `ContextProvider` reads the context registry of the container **at the provider's own scope** — build order is irrelevant. Scope determines which container is read, not timing Setting context on a parent container never reaches a child-scoped provider, regardless of when you call `set_context`: ```python # ❌ Broken: a REQUEST-scoped provider reads the REQUEST container's registry. # Setting it on the APP parent has no effect. app_container = Container() app_container.set_context(CustomContext, value) # ignored for REQUEST-scoped providers request_container = app_container.build_child_container(scope=Scope.REQUEST) ``` For a REQUEST-scoped `ContextProvider`, set the value on the request container: ```python # Option A: pass context directly when building the child request_container = app_container.build_child_container( scope=Scope.REQUEST, context={CustomContext: value} ) # Option B: set on the request container after building it request_container = app_container.build_child_container(scope=Scope.REQUEST) request_container.set_context(CustomContext, value) ``` Setting context on the parent only works when the `ContextProvider`'s scope matches the parent's scope. ## Framework Context Objects Every framework integration auto-registers `ContextProvider`s for its own request/websocket-like objects — you never declare a `ContextProvider` for these yourself. Each integration builds a per-request (or per-message, or per-connection) child container and sets the framework object as context on it before your code resolves anything from it. There are two ways to consume that value: **Implicit usage (type-based resolution).** Annotate a factory parameter with the framework's type; because the integration already registered a matching `ContextProvider`, modern-di resolves it automatically — the same mechanism as [Basic Usage](#basic-usage) above, just with the `ContextProvider` declared by the integration instead of by you. With [FastAPI](https://modern-di.modern-python.org/integrations/fastapi/index.md), the `fastapi.Request` is injected into each per-request child container automatically: ```python from modern_di import Group, Container, Scope, providers import fastapi import modern_di_fastapi def create_request_info(request: fastapi.Request) -> dict[str, str]: return {"method": request.method, "url": str(request.url)} class Dependencies(Group): # Factory uses the request from context (automatically provided by the integration) request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, ) ALL_GROUPS = [Dependencies] app = fastapi.FastAPI() container = Container(groups=ALL_GROUPS) modern_di_fastapi.setup_di(app, container) # setup_di() registers fastapi.Request's ContextProvider, so the graph is complete # from here on — call validate() after this line, not before. container.validate() # The integration creates a REQUEST-scoped child container per request and # injects the fastapi.Request into its context, so `request` is the real object # at runtime. ``` Nothing validates automatically, so the ordering above is what matters: `fastapi.Request`'s `ContextProvider` only exists once `setup_di()` has registered it, so calling `container.validate()` **before** that line would raise [`ValidationFailedError`](https://modern-di.modern-python.org/troubleshooting/validation-failed-error/index.md) — its `.errors` would carry an [`ArgumentResolutionError`](https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/index.md) for the required `request` parameter, since the provider isn't there yet. Call `validate()` after `setup_di()`, as above, and a required parameter validates cleanly. See [Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/#lifecycle-rules) for the same rule from the integration author's side. If you need to validate the rest of the graph before `setup_di()` runs — e.g. as part of a narrower, construction-time check — make the parameter optional instead (`request: fastapi.Request | None = None`), so `validate()` skips it regardless of whether the connection provider is registered yet; at runtime the integration still injects the real `Request`, since it always sets the per-request context before resolving. A defaulted `Factory` parameter keeps its own disposition here too: `ContextValueNotSetError` (see [When no value is set](#when-no-value-is-set) above) affects only a *direct* resolve of an unset context type, not a defaulted parameter, which still falls back to its default when no context is set. **Explicit usage (provider-based resolution).** Every integration also exports the underlying `ContextProvider` object itself (e.g. `fastapi_request_provider`, `litestar_request_provider`, `aiohttp_request_provider`, `faststream_message_provider`) so you can wire it through `kwargs` instead of relying on type-based resolution — useful with `skip_creator_parsing=True`, or when the parameter name doesn't match the type: ```python kwargs={"request": fastapi_request_provider} # explicit wiring, see Factories: kwargs ``` Each integration's own page has its exact provider names, scopes, and API table: [FastAPI](https://modern-di.modern-python.org/integrations/fastapi/#framework-context-objects), [Litestar](https://modern-di.modern-python.org/integrations/litestar/#framework-context-objects), [Starlette](https://modern-di.modern-python.org/integrations/starlette/#framework-context-objects), [FastStream](https://modern-di.modern-python.org/integrations/faststream/#framework-context-objects), [aiohttp](https://modern-di.modern-python.org/integrations/aiohttp/#api). ## See also - [Factories](https://modern-di.modern-python.org/providers/factories/index.md) — how factories receive injected context values. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — choosing the scope a `ContextProvider` is bound to. - [Container](https://modern-di.modern-python.org/providers/container/index.md) — `build_child_container` and `set_context`. - [FastAPI integration](https://modern-di.modern-python.org/integrations/fastapi/index.md) — framework-provided context objects. # Container Provider The Container Provider is a special provider that you should not initialize. It is automatically registered with each container, so you can resolve the container itself directly. ## Injecting the Container Itself You can inject the container into your dependencies in two ways: ### Automatic Injection (Type-Based) If your creator function has a parameter annotated with `Container`, it will be automatically resolved: ```python from modern_di import Container, Group, Scope, providers def my_creator(di_container: Container) -> str: # Access the container's scope or other properties return f"Container scope: {di_container.scope.name}" class Dependencies(Group): my_factory = providers.Factory(my_creator, scope=Scope.APP) container = Container(groups=[Dependencies]) result = container.resolve(str) # result: "Container scope: APP" ``` ### Explicit Injection You can also explicitly inject the container using `providers.container_provider`. Reach for this when the parameter is **not** annotated as `Container` (so type-based injection can't find it), or when you want an explicit binding instead of relying on the type: ```python from modern_di import Container, Group, Scope, providers def another_creator(di_container: Container) -> str: # The injected container is real — use it return f"resolved from {di_container.scope.name} scope" class Dependencies(Group): another_factory = providers.Factory( another_creator, scope=Scope.APP, kwargs={"di_container": providers.container_provider} ) container = Container(groups=[Dependencies]) result = container.resolve(str) # result: "resolved from APP scope" ``` ## Which container you get Resolving `Container` returns the **calling container** — the deepest, most-specific container in the active chain, not the `APP` root. The `container_provider` simply hands back whichever container ran the resolve, so a `REQUEST` child resolves `Container` to *itself*: ```python app_container = Container(scope=Scope.APP) request_container = app_container.build_child_container(scope=Scope.REQUEST) assert app_container.resolve(Container) is app_container assert request_container.resolve(Container) is request_container # the child, not the APP root ``` The same holds for type-based injection: a creator with a `Container` parameter receives the container that is resolving it. This means request-scoped code reaches the request container (and its context/cache), while app-scoped code reaches the app container. ## Registering providers after construction `container.add_providers(*providers)` registers additional providers on a **root** container after it's built — the blessed seam framework integrations use instead of reaching into `providers_registry` directly. Raises `ChildContainerRegistrationError` if called on a child container. See [Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/#the-contract) for the full contract. ## Resolving a provider or type `container.resolve_dependency(dep)` accepts either a provider reference or a type and dispatches to `resolve_provider` or `resolve` accordingly — the single entry point integrations use to resolve a `FromDI`-style marker. See [Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/#the-contract). ## See also - **Context propagation** — how context values reach (and don't reach) a `ContextProvider` is covered on the [Context Providers](https://modern-di.modern-python.org/providers/context/#context-propagation) page. - **Low-level API** — `find_container`, `scope_map`, and `Group.get_providers()` are documented under [Advanced / low-level API](https://modern-di.modern-python.org/providers/advanced-api/index.md). # Alias `Alias` lets one type resolve to whatever provider already handles a different type. The most common use is binding an abstract base or `Protocol` to a concrete implementation that is already registered, without registering the implementation twice. Resolving the alias calls the source's resolver directly, so overrides and caching on the source provider apply transparently. ## Parameters `Alias(source_type, *, bound_type=UNSET)` — `source_type` may also be passed as a keyword (`source_type=`). ### source_type The type whose registered provider should answer the call. At resolution time, the alias looks up `source_type` in the providers registry and delegates to that provider. If `source_type` is not registered, an `AliasSourceNotRegisteredError` is raised. ### bound_type The type the alias is registered under in the providers registry — i.e. the type you pass to `container.resolve(...)`. Defaults to `source_type` (which makes the alias a no-op); set it to the abstract or `Protocol` type you want resolvable. An alias holds no instance and applies no caching; its effective scope is derived from its source provider. ## Basic Usage ```python import dataclasses from typing import Protocol from modern_di import Container, Group, Scope, providers class Repository(Protocol): def fetch(self) -> list[str]: ... @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class PostgresRepository: dsn: str = "postgres://localhost" def fetch(self) -> list[str]: return ["row-1", "row-2"] class Dependencies(Group): repo = providers.Factory( PostgresRepository, cache=True, ) abstract_repo = providers.Alias( PostgresRepository, bound_type=Repository, ) container = Container(groups=[Dependencies]) concrete = container.resolve(PostgresRepository) abstract = container.resolve(Repository) # Both resolve to the same instance — the alias delegates to the # cached source factory. assert concrete is abstract ``` ## Sharing the source's cache Because `Alias` does not cache anything itself, callers automatically share whatever instance the source provider returns. With a cached `Factory`, every resolution path — by the concrete type, by the abstract type, or via a downstream factory parameter typed as the abstract — returns the same singleton. With an uncached source `Factory`, each resolution still goes through the source factory, so each call produces a new instance (matching the source factory's own behavior). ## Overrides Overrides are keyed by `provider_id`, so the alias and its source can be overridden independently. See [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) for the `container.override` / `reset_override` primitives. ```python mock_for_alias = PostgresRepository(dsn="alias-mock") container.override(Dependencies.abstract_repo, mock_for_alias) assert container.resolve(Repository) is mock_for_alias # The source provider is untouched. assert container.resolve(PostgresRepository) is not mock_for_alias ``` Note: an active override on the alias takes precedence over an override on its source for the aliased type, so reset the alias override first if you want the source override to win. ```python container.reset_override(Dependencies.abstract_repo) ``` Override the source provider instead, and both resolution paths see the mock: ```python mock_for_source = PostgresRepository(dsn="source-mock") container.override(Dependencies.repo, mock_for_source) assert container.resolve(PostgresRepository) is mock_for_source assert container.resolve(Repository) is mock_for_source ``` ## Validation and cycle detection `Alias` participates in `container.validate()`: - If `source_type` is not registered, `AliasSourceNotRegisteredError` is raised eagerly. - The alias reports the source provider as a dependency, so cycles that pass through an alias are detected and reported via `CircularDependencyError` — see [Troubleshooting: Circular dependency](https://modern-di.modern-python.org/troubleshooting/circular-dependency/index.md). Scope is checked transitively through `validate()` `Container.validate()` checks scope transitively through aliases. A shallow-scoped caller that depends — via an alias — on a deeper-scoped source is flagged with `InvalidScopeDependencyError` at validation time — the same [scope dependency rule](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) enforced everywhere else, applied through the alias's source chain instead of letting it surface as `ScopeNotInitializedError` at runtime. # Errors and exceptions Every exception `modern-di` raises lives in `modern_di.exceptions` and descends from a single root, `ModernDIError`. The hierarchy is grouped by *when* the failure happens — registering providers, validating the graph, resolving a type, or closing a container — so you can catch a whole category with one `except`. ```python from modern_di import exceptions ``` ## Hierarchy ```text ModernDIError (RuntimeError) ├── ContainerError │ ├── InvalidChildScopeError │ ├── MaxScopeReachedError │ ├── ScopeNotInitializedError │ ├── ScopeSkippedError │ ├── InvalidScopeTypeError │ ├── ContainerClosedError │ └── ValidationFailedError ├── ResolutionError │ ├── ProviderNotRegisteredError │ ├── AliasSourceNotRegisteredError │ ├── ArgumentResolutionError │ ├── CircularDependencyError │ ├── CreatorCallError │ └── ContextValueNotSetError ├── RegistrationError │ ├── DuplicateProviderTypeError │ ├── ChildContainerRegistrationError │ ├── GroupScopeConflictError │ ├── ProviderScopeFrozenError │ ├── UnknownFactoryKwargError │ ├── UnsupportedCreatorParameterError │ └── InvalidScopeDependencyError ├── FinalizerError ├── AsyncFinalizerInSyncCloseError └── GroupInstantiationError ``` ## Root - **`ModernDIError`** — base class for every error the library raises. It subclasses `RuntimeError` for backwards compatibility, so `except RuntimeError` keeps working. Catch `ModernDIError` to handle any framework error in one place. ## `ContainerError` — container and scope problems Catch `ContainerError` for any container/scope failure. - **`InvalidChildScopeError`** — raised when `build_child_container(scope=...)` is given a scope that is not deeper than the parent's (or the constructor receives a parent at an equal/shallower scope). The error lists the scopes that *are* allowed. See [Troubleshooting: InvalidChildScopeError](https://modern-di.modern-python.org/troubleshooting/invalid-child-scope-error/index.md). - **`MaxScopeReachedError`** — raised by `build_child_container()` with no explicit `scope` when the parent is already at the deepest scope (`STEP`), so there is no next level to advance to. See [Troubleshooting: MaxScopeReachedError](https://modern-di.modern-python.org/troubleshooting/max-scope-reached-error/index.md). - **`ScopeNotInitializedError`** — raised during resolution when a provider needs a scope *deeper* than the current container's, and no container at that scope exists in the chain (e.g. resolving a `REQUEST`-scoped provider from the `APP` container). Like `ResolutionError`, it carries a breadcrumb `dependency_path`: a runtime *captive dependency* (a shallower-scoped provider depending, directly or transitively, on this deeper-scoped one) names both the capturing provider and the one that actually failed, not just the two scope names. See [Troubleshooting: ScopeNotInitializedError](https://modern-di.modern-python.org/troubleshooting/scope-not-initialized-error/index.md). - **`ScopeSkippedError`** — raised during resolution when the target scope is *shallower* than the current container but is missing from the scope chain (a level was skipped when building children). Carries the same breadcrumb `dependency_path` as `ScopeNotInitializedError`. See [Troubleshooting: ScopeSkippedError](https://modern-di.modern-python.org/troubleshooting/scope-skipped-error/index.md). - **`InvalidScopeTypeError`** — raised by the `Container` constructor when `scope` is not an `enum.IntEnum`. See [Troubleshooting: InvalidScopeTypeError](https://modern-di.modern-python.org/troubleshooting/invalid-scope-type-error/index.md). - **`ContainerClosedError`** — no longer raised as of modern-di 3.1; kept importable for back-compat and removed in 4.0. A container is open from construction, so there is nothing to raise: resolving from a container that was **explicitly closed** — directly, or through a child whose resolve reaches back into its scope — reopens it and emits `ContainerClosedWarning` (a `RuntimeWarning`, not a `ModernDIError`) instead. `build_child_container()` itself never checks or touches any container's open/closed state — building a child of a closed parent triggers neither the reopen nor the warning by itself. Re-enter the container via `with`/`async with`, or call `container.open()`, to reopen it deliberately (silently) instead — see [Lifecycle: closing and reopening](https://modern-di.modern-python.org/providers/lifecycle/#closing-and-reopening). See [Troubleshooting: ContainerClosedError](https://modern-di.modern-python.org/troubleshooting/container-closed-error/index.md). - **`ValidationFailedError`** — raised only by `Container.validate()`. Catch this for validation results; its `.errors` attribute holds the list of individual issues (each itself a `ResolutionError` or `RegistrationError`), and `str()` renders them all, grouped by error kind. Nothing validates automatically — not construction, not `open()`, not `add_providers`, not `resolve()` — so call `validate()` explicitly whenever you want the whole graph checked; an integration that registers its own providers after construction (via `add_providers`) should call it **after** that registration. `Container(validate=...)` is a deprecated no-op: passing `True` or `False` emits `ValidateArgumentWarning` and gates nothing. See [Lifecycle: validation](https://modern-di.modern-python.org/providers/lifecycle/#validation), [Migration: To 3.x](https://modern-di.modern-python.org/migration/to-3.x/index.md) and [Troubleshooting: ValidationFailedError](https://modern-di.modern-python.org/troubleshooting/validation-failed-error/index.md). ## `ResolutionError` — failures while resolving a type Catch `ResolutionError` for any resolution failure. These carry a `dependency_path` that is accumulated as the error propagates, so the message shows the full chain from the requested type down to the failing dependency. `dependency_path` is a `list[ResolutionStep]`, where each `ResolutionStep` (importable from `modern_di.exceptions`) has a `.scope` and a `.name` — inspect it to render the chain programmatically. `ScopeNotInitializedError` and `ScopeSkippedError` (below) carry the same `dependency_path` — the breadcrumb machinery is shared, not duplicated. - **`ProviderNotRegisteredError`** — raised by `resolve(SomeType)` when no provider is registered for the type. The message includes "did you mean…" suggestions when a close match exists. See [Troubleshooting: Missing provider](https://modern-di.modern-python.org/troubleshooting/missing-provider/index.md). - **`AliasSourceNotRegisteredError`** — raised when an `Alias` points at a `source_type` that has no registered provider (eagerly during `validate()`, or at resolution time). See [Troubleshooting: AliasSourceNotRegisteredError](https://modern-di.modern-python.org/troubleshooting/alias-source-not-registered-error/index.md). - **`ArgumentResolutionError`** — raised when a creator parameter cannot be resolved: no provider matches its annotated type, or the parameter is unannotated. See [Troubleshooting: ArgumentResolutionError](https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/index.md). - **`CircularDependencyError`** — raised when the provider graph contains a cycle (A → B → A); the message shows the cycle path. Raised eagerly by `validate()`, and also by a bare `resolve()` on an unvalidated cyclic graph via a runtime guard — see [Troubleshooting: Circular dependency](https://modern-di.modern-python.org/troubleshooting/circular-dependency/#the-runtime-cycle-guard-without-validate). - **`CreatorCallError`** — raised when a creator's dependencies all resolved but argument binding failed while calling it (the assembled arguments don't match the signature — typically a `kwargs` / `skip_creator_parsing` mismatch). Exceptions raised *inside* the creator body propagate unchanged, never wrapped. The binding `TypeError` is preserved on `.original_error` (and as the `__cause__`). See [Troubleshooting: CreatorCallError](https://modern-di.modern-python.org/troubleshooting/creator-call-error/index.md). - **`ContextValueNotSetError`** — raised when an unset `ContextProvider` is resolved *directly* (`container.resolve(SomeContextType)` with no value set); there is no fallback. See [Migration: To 3.x](https://modern-di.modern-python.org/migration/to-3.x/#5-direct-resolve-of-an-unset-contextprovider-raises). Only the direct-resolve path is affected — a `Factory` parameter backed by the same `ContextProvider` keeps following its own default/nullable/required disposition. Inspect `.context_type`. See [Troubleshooting: Context not set](https://modern-di.modern-python.org/troubleshooting/context-not-set/index.md). ## `RegistrationError` — declaration / registration problems Catch `RegistrationError` for declaration- and registration-time problems. - **`DuplicateProviderTypeError`** — raised when two providers are registered for the same bound type (within one group, across groups passed together, or against an already-registered type). See [Troubleshooting: Duplicate type](https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/index.md). - **`ChildContainerRegistrationError`** — raised by `Container.add_providers()` when called on a child container; registration is root-only because the providers registry is shared tree-wide, so registering from a child would mutate every container in the tree. Call `add_providers` on the root container instead. Inspect `.scope` for the offending child container's scope. See [Container: registering after construction](https://modern-di.modern-python.org/providers/container/#registering-providers-after-construction) and [Troubleshooting: ChildContainerRegistrationError](https://modern-di.modern-python.org/troubleshooting/child-container-registration-error/index.md). - **`GroupScopeConflictError`** — raised when a scope-defaulted provider (no explicit `scope=`) is shared by two `Group` subclasses declared with different `scope=` kwargs; the provider's scope cannot follow both defaults at once, and import order must never be what decides it. Inspect `.provider_name`, `.first_group`/`.first_scope`, and `.second_group`/`.second_scope`. See [Troubleshooting: GroupScopeConflictError](https://modern-di.modern-python.org/troubleshooting/group-scope-conflict-error/index.md). - **`ProviderScopeFrozenError`** — raised when a `Group` would change the scope of a provider that is already registered with a container. Resolvers compiled before the change captured the old scope, so applying it would make the same provider resolve differently through an existing container than through a fresh one. Inspect `.provider_name`, `.group_name`, `.current_scope`, `.new_scope`. See [Troubleshooting: ProviderScopeFrozenError](https://modern-di.modern-python.org/troubleshooting/provider-scope-frozen-error/index.md). - **`UnknownFactoryKwargError`** — raised when `Factory(kwargs={...})` contains a key that is not a parameter of the creator's signature; lists the known parameters and "did you mean" hints. See [Troubleshooting: UnknownFactoryKwargError](https://modern-di.modern-python.org/troubleshooting/unknown-factory-kwarg-error/index.md). - **`UnsupportedCreatorParameterError`** — raised when a creator's signature has a parameter `modern-di` cannot wire (e.g. an unsupported kind); names the parameter and the reason. See [Troubleshooting: UnsupportedCreatorParameterError](https://modern-di.modern-python.org/troubleshooting/unsupported-creator-parameter-error/index.md). - **`InvalidScopeDependencyError`** — raised when a provider depends on another provider bound to a *deeper* scope than its own (a longer-lived provider depending on a shorter-lived one). Surfaced by `validate()`. See [Troubleshooting: Scope chain](https://modern-di.modern-python.org/troubleshooting/scope-chain/index.md). ## Direct `ModernDIError` subclasses These don't fit the register/resolve/validate grouping: - **`FinalizerError`** — raised by `close_sync()` / `close_async()` when one or more finalizers raised during cleanup. The remaining finalizers still run; all errors are aggregated into this single exception. `.finalizer_errors` holds the list and `.is_async` records which close path ran. See [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/#close-failure-semantics) and [Troubleshooting: FinalizerError](https://modern-di.modern-python.org/troubleshooting/finalizer-error/index.md). - **`AsyncFinalizerInSyncCloseError`** — raised when `close_sync()` reaches a cached resource whose finalizer is async. Because `close_sync()` aggregates, this arrives *wrapped inside a* `FinalizerError` (as an entry in `.finalizer_errors`), not on its own. The cache is retained so a later `await close_async()` can finalize it. See [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/#close-failure-semantics) and [Troubleshooting: AsyncFinalizerInSyncCloseError](https://modern-di.modern-python.org/troubleshooting/async-finalizer-in-sync-close-error/index.md). - **`GroupInstantiationError`** — raised when a `Group` subclass is instantiated. Groups are namespaces and must never be created as objects. See [Troubleshooting: GroupInstantiationError](https://modern-di.modern-python.org/troubleshooting/group-instantiation-error/index.md). ## Security note `modern-di` exception messages are intended for developers (logs, tracebacks during wiring). A `CreatorCallError` embeds the wrapped exception's text, and a `FinalizerError` embeds the repr of every finalizer exception — so if a creator or finalizer raises an error whose message contains sensitive runtime data, that text becomes part of the `modern-di` message. The DI-specific errors themselves are conservative (type names and provider reprs only; context values are keyed by type and never repr'd). Applications must not echo raw exception strings to untrusted clients. # Advanced / low-level API Lower-level public surface for library authors and advanced use-cases. ## Supported extension points ### `Group.get_providers()` `Group.get_providers()` is a classmethod that traverses the MRO (excluding `Group` and `object`) and collects every class attribute that is an `AbstractProvider` instance, respecting MRO override order (subclass attribute shadows parent attribute of the same name). Use it to inspect or iterate all providers declared on a group hierarchy. The provider set is closed — `AbstractProvider` is not an extension point `Factory`, `Alias`, `ContextProvider`, and the pre-built `container_provider` are the only provider types. `AbstractProvider` is their shared base and the type that appears in public signatures (`resolve_dependency`, `kwargs=`), but it is **not** a hook for adding your own: resolution compiles one closure per known provider type, so a subclass of `AbstractProvider` — or of `Factory` — raises `TypeError` at its first resolve, and `validate()` does not catch it. Compose behavior in a creator function, or use `Alias`, instead of introducing a provider type. ### `CacheSettings.is_async_finalizer` `CacheSettings.is_async_finalizer` is a computed bool field set at construction time via `inspect.iscoroutinefunction(finalizer)`. The cache registry uses it to decide whether to `await` the finalizer during `close_async()` or treat it as sync. ### `find_container(scope)` `find_container(scope)` returns `self` immediately when `scope` is the resolving container's own scope; otherwise it looks `scope` up in `_scope_map` and returns the ancestor registered there, raising `ScopeNotInitializedError` or `ScopeSkippedError` if the scope is absent. It is the primitive the compiled resolvers use to locate the container at a provider's scope when it differs from the resolving container's. ## Container internals — no stability guarantee Internal surface These attributes back the container's own machinery. They are documented for debugging and deep integration work only, and may change without a deprecation cycle. Do not build on them. - **`parent_container`** — constructor kwarg and slot; the direct parent of a child container, or `None` for a root. Passing a `scope ≤ parent.scope` raises `InvalidChildScopeError`. - **`_scope_map`** — `dict[IntEnum, Container]` mapping each **ancestor's** scope to its container; built at construction time, a child inheriting its parent's map plus the parent itself. A root's map is empty. The container is never in its own map — that self-reference would make every container a reference cycle — and `find_container` never needs it, since it short-circuits on its own scope first. - **`_lock`** — a `threading.RLock` instance, or `None` when the container was created with `use_lock=False`. A cached `Factory`'s compiled resolver hands it to `CacheItem.get_or_create`, which gates the cold-miss build so one instance is created per cache key. The former public names `scope_map` and `lock` remain as read-only properties that emit `DeprecationWarning` and will be removed in a future release. # Integrations # Usage with `aiogram` aiogram has no dependency-injection system of its own, so `modern-di-aiogram` uses the `@inject` decorator with `FromDI` markers (or `auto_inject=True` to skip the decorator entirely). `setup_di` opens the root container on dispatcher startup, closes it on shutdown, and installs middleware that opens a per-update child container automatically. ## How to use ### 1. Install `modern-di-aiogram` ```bash uv add modern-di-aiogram ``` ```bash pip install modern-di-aiogram ``` ```bash poetry add modern-di-aiogram ``` ### 2. Apply to your application ```python import dataclasses import typing from aiogram import Dispatcher from aiogram.types import Message from modern_di import Container, Group, Scope, providers from modern_di_aiogram import FromDI, inject, setup_di @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) dispatcher = Dispatcher() container = Container(groups=[AppGroup]) setup_di(dispatcher, container) container.validate() # after setup_di — its connection providers are now registered @dispatcher.message() @inject async def greet( message: Message, report: typing.Annotated[Report, FromDI(Report)], ) -> None: await message.answer(str(report.as_dict())) ``` `setup_di(dispatcher, container)` stores the container on the dispatcher, registers `dispatcher.startup`/`dispatcher.shutdown` handlers that open/close it, and installs an update-level outer middleware that builds a per-update child container. ## Auto-injecting handlers Passing `auto_inject=True` to `setup_di` wraps every handler already registered on the dispatcher with `@inject` automatically, so individual handlers don't need the decorator: ```python import typing from aiogram import Dispatcher, Router from aiogram.types import Message from modern_di import Container, Group, Scope, providers from modern_di_aiogram import FromDI, setup_di class Settings: def __init__(self) -> None: self.greeting = "hello" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) router = Router() @router.message() async def greet( message: Message, settings: typing.Annotated[Settings, FromDI(AppGroup.settings)], ) -> None: await message.answer(f"{settings.greeting}, {message.from_user.first_name}") dispatcher = Dispatcher() dispatcher.include_router(router) container = Container(groups=[AppGroup]) setup_di(dispatcher, container, auto_inject=True) container.validate() # after setup_di — its connection providers are now registered ``` Register handlers before startup `auto_inject` wraps handlers on `dispatcher.startup`, which fires from `dispatcher.emit_startup()` — the call `start_polling()`/`start_webhook()` makes before serving updates. Only handlers registered (via `dispatcher.include_router()` or the decorators directly) **before** `emit_startup()` runs are wrapped; a handler added afterward is invoked without injection and any `FromDI` parameter on it is left unresolved. ## Scopes The integration creates one `Scope.REQUEST` child container **per update**. The middleware is installed on `dispatcher.update` as an [outer middleware](https://docs.aiogram.dev/en/latest/dispatcher/middlewares.html), so it wraps every update regardless of which router or handler ultimately processes it. The child container is closed after the handler runs — including when it raises. There is no `Scope.SESSION` for aiogram — each Telegram update is handled independently; there's no persistent per-chat/per-user connection comparable to a WebSocket. See [the scope hierarchy](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule). ## Sync resolution, async cleanup `FromDI` resolves its dependency with `Container.resolve_dependency(...)`, which is synchronous — modern-di's resolution is always sync, regardless of the framework. The per-update `Scope.REQUEST` child container that resolution runs against is nevertheless torn down asynchronously: after the handler finishes (or raises), the integration awaits `child_container.close_async()`. So async finalizers on REQUEST-scoped providers run correctly, while the factories themselves must build synchronously. ## Framework context objects `aiogram.types.Update` and the concrete event it carries (`Message`, `CallbackQuery`, etc.) are automatically made available by the integration, so factories can declare them as parameters — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context providers are also available for explicit import: - `aiogram_update_provider` — provides the current `aiogram.types.Update`. - `aiogram_event_provider` — provides the current `aiogram.types.TelegramObject`, the concrete event unwrapped from the `Update` (e.g. a `Message` or `CallbackQuery` instance). ### Implicit (type-based) usage ```python from aiogram.types import TelegramObject, Update from modern_di import Group, Scope, providers def create_update_info(update: Update, event: TelegramObject) -> dict[str, str]: return { "update_id": str(update.update_id), "event_type": type(event).__name__, } class AppGroup(Group): # Update and TelegramObject are resolved by type annotation update_info = providers.Factory( create_update_info, scope=Scope.REQUEST, ) ``` ### Explicit (provider-based) usage `aiogram_event_provider` is bound to the base `TelegramObject` type, so narrowing a parameter to a concrete event type (like `Message`) requires wiring it explicitly with `FromDI`: ```python import typing from aiogram.types import Message from modern_di_aiogram import FromDI, aiogram_event_provider, inject @inject async def log_message( message: Message, same_message: typing.Annotated[Message, FromDI(aiogram_event_provider)], ) -> None: assert message is same_message ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and container teardown. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `setup_di(dispatcher, container, *, auto_inject=False)` | Stores the container on the dispatcher, registers `aiogram_update_provider`/`aiogram_event_provider`, wires `dispatcher.startup`/`dispatcher.shutdown` to open/close the container, and installs the per-update middleware. With `auto_inject=True`, also wraps every handler already registered on the dispatcher at startup. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-update child container. | | `inject` | Decorator for an aiogram handler; resolves its `FromDI`-annotated parameters. Not needed when `setup_di(..., auto_inject=True)` is used. | | `fetch_di_container(dispatcher)` | Returns the root `Container` stored on the dispatcher. | | `aiogram_update_provider` | `ContextProvider` for the current `aiogram.types.Update` (REQUEST scope). | | `aiogram_event_provider` | `ContextProvider` for the current `aiogram.types.TelegramObject` (REQUEST scope) — the concrete event unwrapped from the `Update`. | ## Usage with `aiogram-dialog` [aiogram-dialog](https://github.com/Tishka17/aiogram_dialog) runs inside aiogram's dispatch, so the per-update child container that `setup_di`'s middleware already builds is reachable from dialog code. `modern_di_aiogram.dialog` adds a dialog-aware `inject` for **getters** and **callbacks** (`on_click`, `on_start`/`on_close`, `on_process_result`) — install it with the normal `setup_di(...)` and decorate your dialog functions: ```python import typing from aiogram_dialog import DialogManager from modern_di import Group, Scope, providers from modern_di_aiogram.dialog import FromDI, inject class Settings: def __init__(self) -> None: self.greeting = "hello" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) @inject async def getter( dialog_manager: DialogManager, settings: typing.Annotated[Settings, FromDI(Settings)], # resolve by type **kwargs: typing.Any, # required by aiogram-dialog ) -> dict[str, str]: return {"greeting": settings.greeting} @inject async def on_click( callback: typing.Any, button: typing.Any, manager: DialogManager, settings: typing.Annotated[Settings, FromDI(Settings)], ) -> None: await manager.done(result=settings.greeting) ``` The container is found by call shape: a getter receives it via `**manager.middleware_data` (aiogram-dialog calls `getter(**middleware_data)`), and a callback via the positional `DialogManager`'s `.middleware_data`. Dialog DI requires the normal `setup_di(dispatcher, container)` — its middleware provides the per-update container. - `modern_di_aiogram.dialog` has **no runtime dependency** on `aiogram-dialog`; install `aiogram-dialog` yourself. - The `FromDI` marker is the same one used for handlers — it is re-exported from `modern_di_aiogram.dialog` for convenience. - An `@inject` getter must still declare `**kwargs` (aiogram-dialog always calls getters with the full `middleware_data`), and a `FromDI` getter parameter must not share a name with a `middleware_data` key (e.g. `bot`, `event`). # Usage with `aiohttp` aiohttp has no dependency-injection system of its own, so `modern-di-aiohttp` uses the `@inject` decorator with `FromDI` markers. `setup_di` opens the root container on app startup, closes it on cleanup, and installs middleware that opens a per-connection child container automatically. ## How to use ### 1. Install `modern-di-aiohttp` ```bash uv add modern-di-aiohttp ``` ```bash pip install modern-di-aiohttp ``` ```bash poetry add modern-di-aiohttp ``` ### 2. Apply to your application ```python import dataclasses import typing from aiohttp import web from modern_di import Container, Group, Scope, providers from modern_di_aiohttp import FromDI, inject, setup_di @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) @inject async def get_report( request: web.Request, report: typing.Annotated[Report, FromDI(Report)], ) -> web.Response: return web.json_response(report.as_dict()) app = web.Application() app.router.add_get("/report", get_report) container = Container(groups=[AppGroup]) setup_di(app, container) container.validate() # after setup_di — its connection providers are now registered ``` ### 3. Scopes See [the scope hierarchy](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) — an HTTP request opens a `Scope.REQUEST` child container; a WebSocket connection opens a `Scope.SESSION` one. Which scope gets opened is decided per-connection: the middleware checks the request's handshake headers (via aiohttp's `can_prepare`), not the route or handler. A request carrying WebSocket-upgrade headers opens a `Scope.SESSION` child regardless of which handler ultimately serves it. ### 4. WebSockets and per-message scope A WebSocket handler runs for the whole life of the socket, so its `Scope.SESSION` container does too. Read the connection with `FromDI(aiohttp_websocket_provider)`. Unlike FastAPI, Litestar, and Starlette, aiohttp has no separate WebSocket object — a WebSocket is an upgraded `web.Request`. So `aiohttp_websocket_provider` binds `web.Request` too, and is declared `bound_type=None` (not resolvable by type, because `aiohttp_request_provider` already owns `web.Request`). That is why you wire it **explicitly** with `FromDI(aiohttp_websocket_provider)` rather than by type annotation. For per-message work, open a nested `Scope.REQUEST` child of the session container, fetched via `fetch_request_container`: ```python import typing from aiohttp import web from modern_di import Scope from modern_di_aiohttp import FromDI, aiohttp_websocket_provider, fetch_request_container, inject @inject async def ws_handler( request: web.Request, connection: typing.Annotated[web.Request, FromDI(aiohttp_websocket_provider)], ) -> web.WebSocketResponse: session_container = fetch_request_container(request) ws = web.WebSocketResponse() await ws.prepare(request) async for msg in ws: if msg.type == web.WSMsgType.TEXT: async with session_container.build_child_container(scope=Scope.REQUEST) as request_container: ... # resolve REQUEST-scoped providers for this message return ws ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async SQLAlchemy](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — engine + session + repository through the request container. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(app, container)` | Opens the root container on startup, closes it on cleanup, and installs the middleware that builds a per-connection child container; returns the container. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-connection child container. | | `inject` | Decorator for an `async def handler(request: web.Request, ...)`; resolves its `FromDI`-annotated parameters. | | `fetch_di_container(app)` | Returns the root `Container` stored on the app. | | `fetch_request_container(request)` | Returns the per-connection child container the middleware built (REQUEST for HTTP, SESSION for a WebSocket). | | `aiohttp_request_provider` | `ContextProvider` for `web.Request` (REQUEST scope), auto-registered by type. | | `aiohttp_websocket_provider` | `ContextProvider` for the WebSocket connection's `web.Request` (SESSION scope), `bound_type=None` — resolve via `FromDI(aiohttp_websocket_provider)`. | # Usage with `arq` ## How to use ### 1. Install `modern-di-arq` ```bash uv add modern-di-arq ``` ```bash pip install modern-di-arq ``` ```bash poetry add modern-di-arq ``` ### 2. Apply to your application ```python import dataclasses import typing from arq.connections import RedisSettings from modern_di import Container, Group, Scope, providers from modern_di_arq import FromDI, inject, setup_di @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def render(self) -> str: return f"service={self.settings.service_name}" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) @inject async def run_report( ctx: dict[str, typing.Any], # arq passes its context dict as the first argument report: typing.Annotated[Report, FromDI(Report)], ) -> str: return report.render() class WorkerSettings: functions = [run_report] redis_settings = RedisSettings(host="localhost") container = Container(groups=[AppGroup]) setup_di(WorkerSettings, container) container.validate() # fails fast on a broken graph before the worker runs ``` Run the worker as usual — `arq mymodule.WorkerSettings` — and enqueue jobs from anywhere: ```python from arq import create_pool from arq.connections import RedisSettings async def main() -> None: pool = await create_pool(RedisSettings(host="localhost")) await pool.enqueue_job("run_report") ``` `setup_di(worker_settings, container)` seeds the container into arq's `ctx` dict (arq's per-worker state store) and wraps four of arq's lifecycle hooks: `on_startup`/`on_shutdown` open and close the root container, and `on_job_start`/`on_job_end` build and close a `Scope.REQUEST` child container around each job. Any hook you already defined still runs — yours runs *after* ours on startup/job-start and *before* ours on shutdown/job-end, so your code always sees a live container. It accepts a `WorkerSettings` class (the common case) or a plain settings `dict`, and returns the container. `@inject` resolves each `FromDI`-annotated parameter from the per-job child container and forwards it to your task. Your task **must** declare arq's `ctx` dict as its first parameter (arq calls every task as `task(ctx, *args)`). Injection is parameter-order-insensitive — a `FromDI` parameter may sit anywhere in the signature — and a task with no `FromDI` parameter is returned unchanged. ## Scopes The integration builds one `Scope.REQUEST` child container **per job**. It is created in `on_job_start` and closed with `close_async()` in `on_job_end`, which arq runs whether the job succeeded or raised — so REQUEST-scoped providers (and their finalizers) live exactly for the duration of one job and never leak on the error path. APP-scoped providers persist for the whole worker: `setup_di` opens the root container on `on_startup` and closes it on `on_shutdown`, running APP-scoped finalizers once when the worker stops. There is no `Scope.SESSION` for arq — a job queue has no session concept comparable to a websocket connection. ## Async resolution, no connection object `FromDI` resolves its dependency with `Container.resolve_dependency(...)`, which is synchronous — modern-di's resolution is always sync, regardless of the framework. Container *lifecycle* here is async, matching arq: the root and each per-job child are closed with `close_async()`, so REQUEST- and APP-scoped finalizers may be async (or sync). arq's per-job `ctx` is a plain `dict` (`job_id`, `job_try`, `redis`, ...), not a dedicated request/message type, so — like Celery and Typer — `modern_di_arq` registers no context provider. A task that needs job metadata reads it from the `ctx` argument arq already passes. If you need the root container elsewhere (for example in your own `on_job_start`), `fetch_di_container(ctx)` returns it. ## Restart safety `setup_di` wires `container.open()` onto `on_startup`, and calling `open()` again on an already-open container is a no-op — it unconditionally clears `closed` and runs no validation, so it costs nothing regardless of graph state. A worker that starts, stops (closing the container), and starts again — a restart, or a test that runs the worker twice — reopens the same container cleanly. Calling `setup_di` twice on the same `worker_settings` is rejected with a `TypeError`, since stacking the hook wrappers would leak a per-job child container. ## Tasks with `*args`/`**kwargs` `@inject` resolves dependencies by binding the task signature by name, which is what makes injection order-insensitive. A task that mixes a `FromDI` parameter with `*args` or `**kwargs` cannot be bound unambiguously, so `@inject` raises a `TypeError` **at decoration time** rather than silently misrouting arguments. Give an `@inject` task explicit named parameters. (A task with no `FromDI` parameter is untouched and may use `*args`/`**kwargs` freely.) ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) — constructing async resources with finalizers. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(worker_settings, container)` | Seed the root container into arq's `ctx` and wire root + per-job lifecycle onto arq's `on_startup`/`on_shutdown`/`on_job_start`/`on_job_end` hooks. Accepts a `WorkerSettings` class/object or a settings `dict`; composes with existing hooks; returns the container. Raises `TypeError` if called twice on the same `worker_settings`. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. | | `@inject` | Decorator that resolves `FromDI`-annotated parameters from the per-job `Scope.REQUEST` child container. Order-insensitive; passthrough for tasks with no `FromDI`; raises `TypeError` at decoration if the task also declares `*args`/`**kwargs`. | | `fetch_di_container(ctx)` | Returns the root container from an arq `ctx` dict. | # Usage with `Celery` ## How to use ### 1. Install `modern-di-celery` ```bash uv add modern-di-celery ``` ```bash pip install modern-di-celery ``` ```bash poetry add modern-di-celery ``` ### 2. Apply to your application ```python import dataclasses import typing from celery import Celery from modern_di import Container, Group, Scope, providers from modern_di_celery import FromDI, inject, setup_di @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def render(self) -> str: return f"service={self.settings.service_name}" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) app = Celery("myapp", broker="redis://localhost") container = Container(groups=[AppGroup]) setup_di(app, container) container.validate() # fails fast on a broken graph before the worker runs @app.task @inject def run_report(report: typing.Annotated[Report, FromDI(Report)]) -> str: return report.render() ``` `setup_di(app, container)` stores the container on `app.conf` and registers `worker_process_init`/`worker_process_shutdown` signal handlers that open/close it — those fire when a real `celery worker` process starts and stops, so a script or test that calls tasks without spinning one up (e.g. with `task_always_eager = True`) must drive the container lifecycle itself; see [Worker-process lifecycle](#worker-process-lifecycle) below. `@inject` builds a `Scope.REQUEST` child container per call and resolves `FromDI`-annotated parameters from it — it looks the container up through Celery's `current_app` proxy at call time, not the `app` object captured at decoration time, so it always resolves against whichever app is currently active. ## Scopes The integration creates a `Scope.REQUEST` child container **for each task invocation**, whether wired via `@inject` or [`DITask`](#the-ditask-base-class). REQUEST-scoped providers (and their finalizers) live for the duration of that one call; the child container is closed with `close_sync()` once the task returns, including when it raises. APP-scoped providers persist for the whole worker process — `setup_di` opens the APP container on `worker_process_init` and closes it with `close_sync()` on `worker_process_shutdown`. There is no `Scope.SESSION` for Celery — a task queue doesn't have a session concept comparable to websockets. ## Sync resolution, no connection object `FromDI` resolves its dependency with `Container.resolve_dependency(...)`, which is synchronous — modern-di's resolution is always sync, regardless of the framework. Celery tasks are themselves sync callables, so the per-task `Scope.REQUEST` container is torn down the same way it's built: `@inject` calls `close_sync()` in a `finally` block after the task returns. There is no async counterpart — REQUEST-scoped finalizers must be sync (or `close_sync`-compatible) for Celery tasks. Unlike aiohttp, FastAPI, or taskiq, a Celery task has no framework request or message object comparable to an HTTP request or a broker message — `modern_di_celery` does not register a context provider, and there is no implicit/explicit "framework context object" for this integration. Pass whatever per-call data a task needs through its own arguments (or `self.request` on a bound task, which is Celery's own mechanism, unrelated to modern-di). ## The `DITask` base class `DITask` applies `@inject` to a task's `run` method automatically, so individual tasks don't need their own `@inject` decorator. Apply it to every task on an app with `task_cls=DITask`: ```python import typing from celery import Celery from modern_di import Container, Group, Scope, providers from modern_di_celery import DITask, FromDI, setup_di class Settings: def __init__(self) -> None: self.greeting = "hello" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) app = Celery("myapp", broker="redis://localhost", task_cls=DITask) container = Container(groups=[AppGroup]) setup_di(app, container) container.validate() # fails fast on a broken graph before the worker runs @app.task def greet(name: str, settings: typing.Annotated[Settings, FromDI(Settings)]) -> str: return f"{settings.greeting}, {name}" ``` Or apply it to a single task instead of the whole app: ```python import typing from celery import Celery from modern_di import Container, Group, Scope, providers from modern_di_celery import DITask, FromDI, setup_di class Settings: def __init__(self) -> None: self.greeting = "hello" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) app = Celery("myapp", broker="redis://localhost") container = Container(groups=[AppGroup]) setup_di(app, container) container.validate() # fails fast on a broken graph before the worker runs @app.task(base=DITask) def greet(name: str, settings: typing.Annotated[Settings, FromDI(Settings)]) -> str: return f"{settings.greeting}, {name}" ``` `DITask.__init__` wraps `self.run` with `inject` the first time the task class is instantiated (Celery instantiates each task class once per app) and resets `self.__header__` via `head_from_fun` so Celery still binds call arguments against the *visible*, non-DI signature. It skips re-wrapping if `run` is already injected, so stacking an explicit `@inject` under `@app.task(base=DITask)` is safe and only wraps once. ## Worker-process lifecycle `setup_di` connects to Celery's `worker_process_init` and `worker_process_shutdown` signals with `weak=False` — Celery signals default to weak references, which would otherwise let the handlers be garbage-collected before a worker process ever fires them. Both signals fire once per **worker process**, not per task: `container.open()` runs on `worker_process_init`, `container.close_sync()` runs on `worker_process_shutdown`. APP-scoped providers are therefore built once per worker process and torn down when it exits. A real `celery worker` invocation fires both signals automatically. Code that calls tasks without a running worker — a script, or a test using `task_always_eager` — must trigger the same signals (or drive the container directly) itself: ```python from celery import Celery, signals from modern_di import Container, Group, Scope, providers from modern_di_celery import setup_di class Settings: def __init__(self) -> None: self.greeting = "hello" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) app = Celery("myapp", broker="memory://", backend="cache+memory://") app.conf.task_always_eager = True app.conf.task_store_eager_result = True container = Container(groups=[AppGroup]) setup_di(app, container) @app.task def ping() -> str: return "pong" signals.worker_process_init.send(sender=None) # a real worker fires this on startup print(ping.delay().get()) # -> "pong" signals.worker_process_shutdown.send(sender=None) # a real worker fires this on shutdown ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Multi-Group organization](https://modern-di.modern-python.org/recipes/multi-group/index.md) — structuring a larger container. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and container teardown. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(app, container)` | Wire the APP-scope container into Celery — stores it on `app.conf` and opens/closes it on `worker_process_init`/`worker_process_shutdown`. Returns the container. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. | | `@inject` | Decorator that builds a `Scope.REQUEST` child container per call, resolves `FromDI`-annotated parameters from it, and closes the child container with `close_sync()` afterwards. | | `DITask` | `Task` subclass that applies `@inject` to a task's `run` method automatically; pass `task_cls=DITask` to `Celery(...)` or `base=DITask` to `@app.task(...)`. | | `fetch_di_container(app)` | Returns the APP-scope container registered with the Celery app. | # Usage with `FastAPI` *More advanced example of usage with FastAPI - [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template)* ## How to use ### 1. Install `modern-di-fastapi` ```bash uv add modern-di-fastapi ``` ```bash pip install modern-di-fastapi ``` ```bash poetry add modern-di-fastapi ``` ### 2. Apply to your application ```python import dataclasses import typing import fastapi import modern_di_fastapi from modern_di import Container, Group, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) app = fastapi.FastAPI() container = Container(groups=[AppGroup]) modern_di_fastapi.setup_di(app, container) container.validate() # after setup_di — its connection providers are now registered @app.get("/report") async def get_report( report: typing.Annotated[Report, modern_di_fastapi.FromDI(Report)], ) -> dict[str, str]: return report.as_dict() ``` Deployment: mounted sub-apps and disabled lifespan FastAPI only opens the root container from the ASGI **lifespan** event. A `setup_di`-wired app **mounted as a sub-application** (`app.mount("/sub", subapp)`) never receives that event from its parent, and deployments that disable lifespan (e.g. Mangum `lifespan="off"`) skip it too — requests still succeed (the container is already open from construction), but nothing ever closes it, so its finalizers never run at shutdown. Call `setup_di` on the **top-level served app**, or close the root yourself (`await container.close_async()`) at shutdown. ## Websockets Websockets add `SESSION` scope between `APP` and `REQUEST` — see [the scope hierarchy](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule). `SESSION` covers the lifetime of the websocket connection and is entered automatically; `REQUEST` covers one message and must be entered manually: ```python import typing import fastapi import modern_di import modern_di_fastapi app = fastapi.FastAPI() @app.websocket("/ws") async def websocket_endpoint( websocket: fastapi.WebSocket, session_container: typing.Annotated[modern_di.Container, fastapi.Depends(modern_di_fastapi.build_di_container)], ) -> None: await websocket.accept() async with session_container.build_child_container(scope=modern_di.Scope.REQUEST) as request_container: # REQUEST scope is entered here # You can resolve dependencies here await websocket.send_text("test") await websocket.close() ``` ## Framework Context Objects Framework-specific context objects like `fastapi.Request` and `fastapi.WebSocket` are automatically made available by the integration — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context providers are available for import: - `fastapi_request_provider` - Provides the current `fastapi.Request` object - `fastapi_websocket_provider` - Provides the current `fastapi.WebSocket` object ### Implicit Usage (Type-based Resolution) ```python import fastapi from modern_di import Group, Scope, providers def create_request_info(request: fastapi.Request) -> dict[str, str]: return { "method": request.method, "url": str(request.url), "timestamp": "2023-01-01T00:00:00Z" } class AppGroup(Group): # Factory automatically resolves the request dependency based on type annotation request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, ) ``` ### Explicit Usage (Provider-based Resolution) ```python import fastapi import modern_di_fastapi from modern_di import Group, Scope, providers def create_request_info(request: fastapi.Request) -> dict[str, str]: return { "method": request.method, "url": str(request.url), "timestamp": "2023-01-01T00:00:00Z" } class AppGroup(Group): # Factory explicitly uses the request provider from the integration request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, kwargs={"request": modern_di_fastapi.fastapi_request_provider} ) ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async SQLAlchemy](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — engine + session + repository through the request container. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(app, container)` | Registers the container on the FastAPI app and appends a lifespan that closes it on shutdown (merges with any existing `lifespan=`); returns the container. | | `FromDI(dependency, *, use_cache=True)` | A `fastapi.Depends` wrapper that resolves a provider (or type) from the per-request child container. | | `build_di_container(connection)` | A `fastapi.Depends` callable that yields the per-request child container — REQUEST scope for an HTTP request, SESSION scope for a WebSocket. | | `fastapi_request_provider` | `ContextProvider` for `fastapi.Request` (REQUEST scope), auto-registered. | | `fastapi_websocket_provider` | `ContextProvider` for `fastapi.WebSocket` (SESSION scope), auto-registered. | # Usage with `FastStream` ## How to use ### 1. Install `modern-di-faststream` ```bash uv add modern-di-faststream ``` ```bash pip install modern-di-faststream ``` ```bash poetry add modern-di-faststream ``` ### 2. Apply to your application ```python import dataclasses import typing import faststream from faststream.nats import NatsBroker import modern_di_faststream from modern_di import Container, Group, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) broker = NatsBroker() app = faststream.FastStream(broker=broker) container = Container(groups=[AppGroup]) modern_di_faststream.setup_di(app, container) container.validate() # after setup_di — its connection providers are now registered @broker.subscriber("orders.in") async def handle_order( report: typing.Annotated[Report, modern_di_faststream.FromDI(Report)], ) -> dict[str, str]: return report.as_dict() ``` ## Scopes The integration creates a `Scope.REQUEST` child container **for each message** the subscriber receives. REQUEST-scoped providers (and their finalizers) live for the duration of that one message; APP-scoped providers persist for the whole process. At app shutdown, the integration runs `await container.close_async()` on the APP container. There is no `Scope.SESSION` for FastStream — message brokers don't have a session concept comparable to websockets. ## Framework context objects `faststream.StreamMessage` is automatically made available by the integration, so factories can declare it as a parameter and get the current message — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context provider is also available for explicit import: - `faststream_message_provider` — provides the current `faststream.StreamMessage` object. ### Implicit (type-based) usage ```python import faststream from modern_di import Group, Scope, providers def create_message_info(message: faststream.StreamMessage) -> dict[str, str]: return { "message_id": str(message.message_id), "processed": str(message.processed), } class AppGroup(Group): # The message dependency is resolved by type annotation message_info = providers.Factory( create_message_info, scope=Scope.REQUEST, ) ``` ### Explicit (provider-based) usage ```python import faststream import modern_di_faststream from modern_di import Group, Scope, providers def create_message_info(message: faststream.StreamMessage) -> dict[str, str]: return {"message_id": str(message.message_id)} class AppGroup(Group): message_info = providers.Factory( create_message_info, scope=Scope.REQUEST, kwargs={"message": modern_di_faststream.faststream_message_provider}, ) ``` ## Testing Pair the test broker with `TestApp` in the same `with` statement When testing DI-using subscribers, pair the test broker (`TestNatsBroker`, etc.) with `TestApp` in the **same** `with` / `async with` statement. FastStream's `TestBroker` decides whether to run app `on_startup` hooks by inspecting that statement; `async with TestNatsBroker(broker):` alone starts the broker without running `on_startup` — published messages still get handled (the container is already open from construction), but nothing ever closes it, so its finalizers never run. ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) — constructing async resources with finalizers. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(app, container)` | Wire the APP-scope container into FastStream — creates a REQUEST child container per message and closes the APP container at shutdown. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in subscriber signatures; accepts a provider instance or a plain type. | | `fetch_di_container(app)` | Returns the APP-scope container registered with the FastStream app. | | `faststream_message_provider` | `ContextProvider` for the current `faststream.StreamMessage`. | # Usage with `Flask` Flask has no dependency-injection system of its own, so `modern-di-flask` uses the `@inject` decorator with `FromDI` markers (there is no `Depends`). `setup_di` installs a `before_request`/`teardown_appcontext` pair that opens a per-request `Scope.REQUEST` child container and closes it once the request finishes. Resolution is **sync-only** — the child container is closed with `close_sync()`. ## How to use ### 1. Install `modern-di-flask` ```bash uv add modern-di-flask ``` ```bash pip install modern-di-flask ``` ```bash poetry add modern-di-flask ``` ### 2. Apply to your application ```python import dataclasses import typing from flask import Flask from modern_di import Container, Group, Scope, providers from modern_di_flask import FromDI, inject, setup_di @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class Dependencies(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) app = Flask(__name__) @app.route("/report") @inject def get_report(report: typing.Annotated[Report, FromDI(Report)]) -> dict[str, str]: return report.as_dict() # call setup_di AFTER registering routes container = Container(groups=[Dependencies]) setup_di(app, container) container.validate() # after setup_di — its connection providers are now registered ``` `FromDI(dependency)` accepts either a provider reference (as above) or a plain type, resolved from the per-request child container the middleware built. ### 3. `auto_inject` Pass `auto_inject=True` to `setup_di` to wire every registered view (app routes and blueprint routes alike) without a per-view `@inject`. Because it walks `app.view_functions` at call time, `setup_di` must run **after** all routes — including blueprint routes — have been registered: ```python import typing from flask import Flask from modern_di import Container, Group, Scope, providers from modern_di_flask import FromDI, setup_di class Settings: def __init__(self) -> None: self.greeting = "hello" class Dependencies(Group): settings = providers.Factory(scope=Scope.APP, creator=Settings) app = Flask(__name__) @app.route("/hello/") def hello(name: str, settings: typing.Annotated[Settings, FromDI(Dependencies.settings)]) -> str: return f"{settings.greeting}, {name}" # no @inject needed on individual views container = Container(groups=[Dependencies]) setup_di(app, container, auto_inject=True) container.validate() # after setup_di — its connection providers are now registered ``` A view that already carries `@inject` is left alone — `auto_inject` only wraps views that weren't injected yet. ### 4. Scopes and request lifecycle See [the scope hierarchy](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule). Flask has no websocket concept, so the integration only ever opens one child scope: `before_request` builds a `Scope.REQUEST` child of the root container and stores it on `flask.g`; `teardown_appcontext` closes it with `close_sync()` once the request (including error handling) is done. ### 5. Root container teardown `setup_di` does not close the root container for you — Flask has no application-shutdown hook to run it from. You own root teardown, typically at your own process-shutdown point: ```python from flask import Flask from modern_di import Container, Group, Scope, providers from modern_di_flask import fetch_di_container, setup_di class Dependencies(Group): pass app = Flask(__name__) setup_di(app, Container(groups=[Dependencies])) # ... register an atexit hook, a CLI teardown command, or call this # explicitly wherever your process shuts down: fetch_di_container(app).close_sync() ``` ## Framework Context Objects `flask.Request` is automatically made available by the integration — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context provider is available for import: - `flask_request_provider` — `ContextProvider` for the current `flask.Request` (REQUEST scope), auto-registered by type. ### Implicit Usage (Type-based Resolution) ```python from flask import Request from modern_di import Group, Scope, providers def create_request_info(request: Request) -> dict[str, str]: return {"method": request.method, "url": request.url} class AppGroup(Group): request_info = providers.Factory(create_request_info, scope=Scope.REQUEST) ``` ### Explicit Usage (Provider-based Resolution) ```python from flask import Request from modern_di import Group, Scope, providers from modern_di_flask import flask_request_provider def create_request_info(request: Request) -> dict[str, str]: return {"method": request.method, "url": request.url} class AppGroup(Group): request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, kwargs={"request": flask_request_provider}, ) ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Multi-Group organization](https://modern-di.modern-python.org/recipes/multi-group/index.md) — structuring a larger container. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and container teardown. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(app, container, *, auto_inject=False)` | Registers the container on `app.extensions`, installs the `before_request`/`teardown_appcontext` pair that builds and closes a per-request `Scope.REQUEST` child container, and — if `auto_inject=True` — wraps every currently-registered view with `inject`; returns the container. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-request child container. | | `inject` | Decorator for a view function; resolves its `FromDI`-annotated parameters without rewriting the function's signature. | | `fetch_di_container(app)` | Returns the root `Container` stored on `app.extensions`. | | `flask_request_provider` | `ContextProvider` for `flask.Request` (REQUEST scope), auto-registered by type. | # Usage with `gRPC` ## How to use ### 1. Install `modern-di-grpc` ```bash uv add modern-di-grpc ``` ```bash pip install modern-di-grpc ``` ```bash poetry add modern-di-grpc ``` ### 2. Apply to your application (sync server) `DIInterceptor` is a `grpc.ServerInterceptor`; pass it to `grpc.server(...)`. It opens one `Scope.REQUEST` child container per RPC and resolves `FromDI`-annotated parameters of `@inject`-decorated servicer methods. ```python import typing from concurrent import futures import grpc from modern_di import Container, Group, Scope, providers from modern_di_grpc import DIInterceptor, FromDI, inject from myapp import greeter_pb2, greeter_pb2_grpc # your generated stubs class Settings: def __init__(self) -> None: self.service_name = "catalog" class RpcReport: def __init__(self, settings: Settings, context: grpc.ServicerContext | None = None) -> None: self._settings = settings # APP-scoped, injected by type self._context = context # REQUEST context object, injected by type def line(self) -> str: peer = self._context.peer() if self._context is not None else "unknown" return f"{self._settings.service_name} <- {peer}" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) rpc_report = providers.Factory(RpcReport, scope=Scope.REQUEST) class GreeterService(greeter_pb2_grpc.GreeterServicer): @inject def SayHello( self, request: greeter_pb2.HelloRequest, context: grpc.ServicerContext, report: typing.Annotated[RpcReport, FromDI(RpcReport)], # resolve by type ) -> greeter_pb2.HelloReply: return greeter_pb2.HelloReply(message=report.line()) container = Container(groups=[AppGroup]) server = grpc.server( futures.ThreadPoolExecutor(max_workers=10), interceptors=[DIInterceptor(container)], ) greeter_pb2_grpc.add_GreeterServicer_to_server(GreeterService(), server) container.validate() # after DIInterceptor(container) — it registers ServicerContext's provider server.add_insecure_port("[::]:50051") server.start() server.wait_for_termination() ``` Constructing `DIInterceptor(container)` registers the `ServicerContext` context provider on the container automatically — no separate setup call. Call `container.validate()` after that construction, not before, for the same reason described in [Writing an integration](https://modern-di.modern-python.org/integrations/writing-integrations/#lifecycle-rules). ### 3. Async server (`grpc.aio`) `DIAioInterceptor` is the async twin — pass it to `grpc.aio.server(...)` and write `async def` servicer methods (server-streaming methods as `async` generators): ```python import grpc from modern_di_grpc import DIAioInterceptor, FromDI, inject class GreeterService(greeter_pb2_grpc.GreeterServicer): @inject async def SayHello( self, request: greeter_pb2.HelloRequest, context: grpc.aio.ServicerContext, greeter: typing.Annotated[Greeter, FromDI(Greeter)], ) -> greeter_pb2.HelloReply: return greeter_pb2.HelloReply(message=greeter.greet(request.name)) server = grpc.aio.server(interceptors=[DIAioInterceptor(container)]) ``` `@inject` adapts to the method it decorates — sync method, `async def`, or async generator (server-streaming) — so the same decorator works on any of the four RPC types on either server. ## Scopes The integration opens one `Scope.REQUEST` child container **per RPC call**, for all four RPC types (unary-unary, server-streaming, client-streaming, bidi). The child is created when the RPC starts and closed when it ends — for a streaming RPC it stays open for the whole stream and closes after the last message, including on the error and client-cancellation paths. REQUEST-scoped providers (and their finalizers) live for exactly one RPC. APP-scoped providers persist for the life of the container. There is no `Scope.SESSION` for gRPC — a streaming RPC is one method invocation, modelled as a single REQUEST-scoped unit of work. ## Injecting the `ServicerContext` The `ServicerContext` is injectable at `Scope.REQUEST` — the interceptor registers `grpc_context_provider` on the container when constructed, and seeds the live context per RPC. A factory can depend on it to read RPC metadata, the deadline, or the peer: ```python import grpc from modern_di import Group, Scope, providers def make_caller(context: grpc.ServicerContext | None = None) -> str: return context.peer() if context is not None else "unknown" class AppGroup(Group): caller = providers.Factory(make_caller, scope=Scope.REQUEST) ``` The `| None = None` default lets the provider construct at validation time, when no context is set. The protobuf request `Message` is **not** exposed as a provider (that would add a `protobuf` dependency); the request is already a servicer-method argument. ## Root container lifecycle gRPC has no server startup/shutdown hook, so the **root container's lifecycle is yours to own** (as with Flask). Create the container open, pass it to the interceptor, and close it after the server stops to run APP-scoped finalizers: ```python server.stop(grace=5).wait() container.close_sync() # or: await container.close_async() on grpc.aio ``` ## Resolving without `@inject` Inside a servicer method (or anything it calls during the RPC), `fetch_di_container()` returns the current RPC's child container: ```python from modern_di_grpc import fetch_di_container container = fetch_di_container() # raises LookupError outside an intercepted RPC ``` ## `*args` / `**kwargs` Unlike the Celery/Typer decorator integrations, gRPC always calls a servicer method as `(request, context)`, so `@inject` needs no signature rewrite and imposes no restriction on the method signature beyond the injected parameters. ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and container teardown. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `DIInterceptor(container)` | `grpc.ServerInterceptor` for the sync thread-pool server. Opens a `Scope.REQUEST` child per RPC (`close_sync`); auto-registers `grpc_context_provider`. | | `DIAioInterceptor(container)` | `grpc.aio.ServerInterceptor` for the async server. Same, with `close_async`. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in servicer-method signatures; accepts a provider instance or a plain type. | | `@inject` | Decorates a servicer method to resolve its `FromDI` parameters from the current RPC's child container; adapts to sync / async / async-generator methods. | | `fetch_di_container()` | Returns the current RPC's child container (raises `LookupError` outside an RPC). | | `grpc_context_provider` | `ContextProvider` exposing `grpc.ServicerContext` at `Scope.REQUEST`; auto-registered by the interceptor. | # Usage with `Litestar` *More advanced example of usage with Litestar - [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template)* ## How to use ### 1. Install `modern-di-litestar` ```bash uv add modern-di-litestar ``` ```bash pip install modern-di-litestar ``` ```bash poetry add modern-di-litestar ``` ### 2. Apply to your application ```python import dataclasses import litestar import modern_di_litestar from modern_di import Container, Group, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) @litestar.get("/report", dependencies={"report": modern_di_litestar.FromDI(Report)}) async def get_report(report: Report) -> dict[str, str]: return report.as_dict() container = Container(groups=[AppGroup]) app = litestar.Litestar( route_handlers=[get_report], plugins=[modern_di_litestar.ModernDIPlugin(container)], ) container.validate() # after the plugin is installed — its connection providers are now registered ``` ### Auto-wiring with `autowired_groups` Pass `autowired_groups` to `ModernDIPlugin` to automatically register every provider in those groups as a Litestar dependency, keyed by its attribute name. This lets route handlers declare dependencies as plain parameters without per-route `FromDI` calls: ```python import dataclasses import litestar from modern_di import Container, Group, Scope, providers from modern_di_litestar import ModernDIPlugin @dataclasses.dataclass(kw_only=True) class UserRepository: pass class AppGroup(Group): user_repo = providers.Factory(UserRepository, scope=Scope.REQUEST) ALL_GROUPS = [AppGroup] container = Container(groups=ALL_GROUPS) app = litestar.Litestar( plugins=[ModernDIPlugin(container, autowired_groups=ALL_GROUPS)], ) container.validate() # after the plugin is installed — its connection providers are now registered @litestar.get("/users") async def list_users(user_repo: UserRepository) -> list[str]: ... ``` If the same attribute name appears in multiple groups, a `UserWarning` is emitted and the last group's provider wins. ## Websockets Websockets add `SESSION` scope between `APP` and `REQUEST` — see [the scope hierarchy](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule). `SESSION` covers the lifetime of the websocket connection and is entered automatically; `REQUEST` covers one message and must be entered manually: ```python import dataclasses import litestar from modern_di import Container, Group, Scope, providers import modern_di_litestar @dataclasses.dataclass class MyService: async def handle(self, data: str) -> None: ... class Dependencies(Group): my_service = providers.Factory(MyService, scope=Scope.REQUEST) ALL_GROUPS = [Dependencies] app = litestar.Litestar(plugins=[modern_di_litestar.ModernDIPlugin(Container(groups=ALL_GROUPS))]) @litestar.websocket_listener("/ws") async def websocket_handler( data: str, di_container: Container, # auto-resolved — the plugin registers a "di_container" dependency ) -> None: # For a websocket, di_container is the SESSION-scoped child; enter REQUEST scope here async with di_container.build_child_container(scope=Scope.REQUEST) as request_container: service = request_container.resolve(MyService) await service.handle(data) app.register(websocket_handler) ``` `di_container` is injected by name — the plugin registers it as a Litestar dependency, so you don't need a `FromDI` marker for the container itself. ## Framework Context Objects Framework-specific context objects like `litestar.Request` and `litestar.WebSocket` are automatically made available by the integration — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context providers are available for import: - `litestar_request_provider` - Provides the current `litestar.Request` object - `litestar_websocket_provider` - Provides the current `litestar.WebSocket` object ### Implicit Usage (Type-based Resolution) ```python import litestar from modern_di import Group, providers, Scope def create_request_info(request: litestar.Request) -> dict[str, str]: return { "method": request.method, "url": str(request.url), "timestamp": "2023-01-01T00:00:00Z" } class AppGroup(Group): # Factory automatically resolves the request dependency based on type annotation request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, ) ``` ### Explicit Usage (Provider-based Resolution) ```python import litestar import modern_di_litestar from modern_di import Group, providers, Scope def create_request_info(request: litestar.Request) -> dict[str, str]: return { "method": request.method, "url": str(request.url), "timestamp": "2023-01-01T00:00:00Z" } class AppGroup(Group): # Factory explicitly uses the request provider from the integration request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, kwargs={"request": modern_di_litestar.litestar_request_provider} ) ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async SQLAlchemy](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — engine + session + repository through the request container. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ModernDIPlugin(container, autowired_groups=None)` | Litestar `InitPlugin` that registers the container, composes the lifespan, and (if `autowired_groups` is given) exposes each provider in those groups as a Litestar dependency keyed by attribute name. | | `FromDI(dependency)` | Returns a Litestar `Provide` that resolves a provider or type from the per-request child container. | | `fetch_di_container(app)` | Returns the root `Container` stored on the Litestar app. | | `litestar_request_provider` | `ContextProvider` for `litestar.Request` (REQUEST scope), auto-registered. | | `litestar_websocket_provider` | `ContextProvider` for `litestar.WebSocket` (SESSION scope), auto-registered. | # Usage with `pytest` `modern-di-pytest` turns any DI dependency into a pytest fixture. Two callables cover the entire surface — `modern_di_fixture` for a single dependency and `expose` for bulk-generating one fixture per provider across one or more `Group` subclasses. Don't want the extra dependency? You don't need it: define `di_container` as a session-scoped pytest fixture around `Container(...)` used as a context manager, build a request-scoped child-container fixture from it, and resolve dependencies inside tests with `container.resolve(...)` directly. See the [testing-with-overrides recipe](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) for a worked example of that approach — the rest of this page assumes the plugin. ## How to use ### 1. Install ```bash uv add --dev modern-di-pytest ``` ```bash pip install modern-di-pytest ``` ```bash poetry add --group dev modern-di-pytest ``` ### 2. Define a `di_container` fixture Define it at the highest pytest scope you want. The plugin never builds the container — you own it: ```python import typing import modern_di import pytest from app import ioc @pytest.fixture(scope="session") def di_container() -> typing.Iterator[modern_di.Container]: with modern_di.Container(groups=ioc.ALL_GROUPS) as container: container.validate() # fail fast on a broken graph before any test runs yield container ``` ### 3. Materialize dependencies as fixtures Either in bulk via `expose` or one-by-one via `modern_di_fixture`: ```python from modern_di_pytest import expose, modern_di_fixture from app.ioc import Auth, Billing, Dependencies from app.services import EmailClient # Bulk: every Provider on each group becomes a pytest fixture # named after the class attribute. Pass several groups in one call; # duplicate names across groups raise ValueError. Non-Provider attributes # are skipped. expose(Dependencies, Auth, Billing) # e.g. user_service is the attribute name on the Dependencies group, # so it becomes the user_service fixture used in the tests below. # Manual: a single type or Provider as a named fixture. email_client = modern_di_fixture(EmailClient) ``` ### 4. Use the fixtures in tests Tests receive resolved dependencies by name: ```python from app.services import EmailClient, UserService def test_listing(user_service: UserService) -> None: assert user_service.list_users() == [] def test_email(email_client: EmailClient) -> None: email_client.send("hi") ``` ## Pointing a fixture at a child container Define the child-container fixture yourself, then pass its name via `container_fixture=`: ```python import typing import modern_di import pytest from modern_di_pytest import modern_di_fixture from app.services import UserService @pytest.fixture def request_container( di_container: modern_di.Container, ) -> typing.Iterator[modern_di.Container]: with di_container.build_child_container(scope=modern_di.Scope.REQUEST) as container: yield container request_user_service = modern_di_fixture( UserService, container_fixture="request_container" ) ``` The same `container_fixture=` parameter is also accepted by `expose`, so one or more `Group` subclasses can be exposed against the request container. ## Overrides `modern-di-pytest` deliberately does **not** ship override sugar. Use `Container.override()` directly — it is already backed by a tree-shared `OverridesRegistry`: ```python import modern_di from app.ioc import Dependencies from app.services import UserService from tests.fakes import FakeRepo def test_with_override( di_container: modern_di.Container, user_service: UserService, ) -> None: di_container.override(Dependencies.user_repo, FakeRepo()) try: assert user_service.list_users() == [] finally: di_container.reset_override(Dependencies.user_repo) ``` When `di_container` is session-scoped, prefer to wrap the override in a function-scoped fixture so cleanup is guaranteed: ```python import typing import modern_di import pytest from app.ioc import Dependencies from tests.fakes import FakeRepo @pytest.fixture def mock_user_repo(di_container: modern_di.Container) -> typing.Iterator[None]: di_container.override(Dependencies.user_repo, FakeRepo()) yield di_container.reset_override(Dependencies.user_repo) ``` For deeper patterns (transactional DB sessions, resetting all overrides) see the [testing-with-overrides recipe](https://modern-di.modern-python.org/recipes/testing-overrides/index.md). ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — override patterns beyond fixtures. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — session vs request container fixtures. # Usage with `Starlette` Starlette has no dependency-injection system of its own, so `modern-di-starlette` uses the `@inject` decorator with `FromDI` markers (there is no `Depends`). `setup_di` composes the lifespan and installs middleware that opens a per-connection child container automatically. ## How to use ### 1. Install `modern-di-starlette` ```bash uv add modern-di-starlette ``` ```bash pip install modern-di-starlette ``` ```bash poetry add modern-di-starlette ``` ### 2. Apply to your application ```python import dataclasses import typing from modern_di import Container, Group, Scope, providers from modern_di_starlette import FromDI, inject, setup_di from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) @inject async def get_report( request: Request, report: typing.Annotated[Report, FromDI(Report)], ) -> JSONResponse: return JSONResponse(report.as_dict()) app = Starlette(routes=[Route("/report", get_report)]) container = Container(groups=[AppGroup]) setup_di(app, container) container.validate() # after setup_di — its connection providers are now registered ``` Deployment: mounted sub-apps and disabled lifespan Starlette only opens the root container from the ASGI **lifespan** event. A `setup_di`-wired app **mounted as a sub-application** (`app.mount("/sub", subapp)`) never receives that event from its parent, and deployments that disable lifespan (e.g. Mangum `lifespan="off"`) skip it too — requests still succeed (the container is already open from construction), but nothing ever closes it, so its finalizers never run at shutdown. Call `setup_di` on the **top-level served app**, or close the root yourself (`await container.close_async()`) at shutdown. ### 3. Scopes See [the scope hierarchy](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) — an HTTP request opens a `Scope.REQUEST` child container; a WebSocket connection opens a `Scope.SESSION` one, built by the middleware before your handler runs and kept open for the whole life of the connection. ## Websockets For per-message work within a websocket's `Scope.SESSION` container, open a nested `Scope.REQUEST` child: ```python import typing import modern_di from modern_di import Scope, providers from modern_di_starlette import FromDI, inject from starlette.websockets import WebSocket @inject async def ws_handler( websocket: WebSocket, container: typing.Annotated[modern_di.Container, FromDI(providers.container_provider)], ) -> None: await websocket.accept() async for message in websocket.iter_text(): async with container.build_child_container(scope=Scope.REQUEST) as request_container: ... # resolve REQUEST-scoped providers for this message ``` ## Framework Context Objects Framework-specific context objects like `starlette.requests.Request` and `starlette.websockets.WebSocket` are automatically made available by the integration — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context providers are available for import: - `starlette_request_provider` — the current `starlette.requests.Request` (REQUEST scope) - `starlette_websocket_provider` — the current `starlette.websockets.WebSocket` (SESSION scope) ### Implicit Usage (Type-based Resolution) ```python from starlette.requests import Request from modern_di import Group, Scope, providers def create_request_info(request: Request) -> dict[str, str]: return {"method": request.method, "url": str(request.url)} class AppGroup(Group): request_info = providers.Factory(create_request_info, scope=Scope.REQUEST) ``` ### Explicit Usage (Provider-based Resolution) ```python import modern_di_starlette from starlette.requests import Request from modern_di import Group, Scope, providers def create_request_info(request: Request) -> dict[str, str]: return {"method": request.method, "url": str(request.url)} class AppGroup(Group): request_info = providers.Factory( create_request_info, scope=Scope.REQUEST, kwargs={"request": modern_di_starlette.starlette_request_provider}, ) ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async SQLAlchemy](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — engine + session + repository through the request container. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(app, container)` | Registers the container on `app.state`, composes the lifespan (opens/closes the container), and installs the middleware that builds a per-connection child container; returns the container. | | `FromDI(dependency)` | Marker (used with `@inject`) that resolves a provider or type from the per-connection child container. | | `inject` | Decorator for an \`async def handler(connection: Request | | `fetch_di_container(app)` | Returns the root `Container` stored on `app.state`. | | `starlette_request_provider` | `ContextProvider` for `starlette.requests.Request` (REQUEST scope), auto-registered. | | `starlette_websocket_provider` | `ContextProvider` for `starlette.websockets.WebSocket` (SESSION scope), auto-registered. | # Usage with `taskiq` ## How to use ### 1. Install `modern-di-taskiq` ```bash uv add modern-di-taskiq ``` ```bash pip install modern-di-taskiq ``` ```bash poetry add modern-di-taskiq ``` ### 2. Apply to your application ```python import dataclasses import typing from modern_di import Container, Group, Scope, providers from modern_di_taskiq import FromDI, setup_di from taskiq import InMemoryBroker @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def as_dict(self) -> dict[str, str]: return {"service": self.settings.service_name} class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) broker = InMemoryBroker() container = Container(groups=[AppGroup]) setup_di(broker, container) container.validate() # after setup_di — its connection providers are now registered @broker.task async def get_report( report: typing.Annotated[Report, FromDI(Report)], ) -> dict[str, str]: return report.as_dict() ``` `setup_di(broker, container)` stores the container on `broker.state` and registers `TaskiqEvents.WORKER_STARTUP`/`WORKER_SHUTDOWN` handlers that open/close it — those fire when the broker's worker process starts and stops, so a script that just calls tasks directly (like `InMemoryBroker` in a test) must drive the container lifecycle itself, e.g. `async with broker: ...` or an explicit `container.open()` / `await container.close_async()`. Deployment: `run_receiver_task` skips startup by default `taskiq.api.run_receiver_task(...)` defaults `run_startup=False`, which skips the worker startup that opens the root container — tasks still run (the container is already open from construction), but nothing ever closes it, so its finalizers never run at shutdown. Pass `run_startup=True` (or close the root yourself around consuming) when embedding a receiver with `run_receiver_task`. ## Scopes The integration creates a `Scope.REQUEST` child container **for each task** the worker executes. REQUEST-scoped providers (and their finalizers) live for the duration of that one task — the child container is closed after the task returns, including when it raises. APP-scoped providers persist for the whole worker process; `setup_di` opens the APP container on `WORKER_STARTUP` and runs `await container.close_async()` on `WORKER_SHUTDOWN`. There is no `Scope.SESSION` for taskiq — a task queue doesn't have a session concept comparable to websockets. ## Sync resolution, async cleanup `FromDI` resolves its dependency with `Container.resolve_dependency(...)`, which is synchronous — modern-di's resolution is always sync, regardless of the framework. The per-task `Scope.REQUEST` child container that resolution runs against is nevertheless torn down asynchronously: after the task handler finishes (or raises), the integration awaits `container.close_async()` on it. So async finalizers on REQUEST-scoped providers run correctly, while the factories themselves must build synchronously. ## Framework context objects `taskiq.TaskiqMessage` is automatically made available by the integration, so factories can declare it as a parameter and get the message that triggered the current task — see [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) for how implicit and explicit resolution work. The following context provider is also available for explicit import: - `taskiq_message_provider` — provides the current `taskiq.TaskiqMessage` object. ### Implicit (type-based) usage ```python import taskiq from modern_di import Group, Scope, providers def create_task_info(message: taskiq.TaskiqMessage) -> dict[str, str]: return { "task_id": message.task_id, "task_name": message.task_name, } class AppGroup(Group): # The message dependency is resolved by type annotation task_info = providers.Factory( create_task_info, scope=Scope.REQUEST, ) ``` ### Explicit (provider-based) usage ```python import taskiq import modern_di_taskiq from modern_di import Group, Scope, providers def create_task_info(message: taskiq.TaskiqMessage) -> dict[str, str]: return {"task_id": message.task_id} class AppGroup(Group): task_info = providers.Factory( create_task_info, scope=Scope.REQUEST, kwargs={"message": modern_di_taskiq.taskiq_message_provider}, ) ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) — constructing async resources with finalizers. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `setup_di(broker, container)` | Wire the APP-scope container into taskiq — creates a REQUEST child container per task and opens/closes the APP container on worker startup/shutdown. | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]` in task signatures; accepts a provider instance or a plain type. | | `fetch_di_container(broker)` | Returns the APP-scope container registered with the taskiq broker. | | `taskiq_message_provider` | `ContextProvider` for the current `taskiq.TaskiqMessage`. | # Usage with `Typer` ## How to use ### 1. Install `modern-di-typer` ```bash uv add modern-di-typer ``` ```bash pip install modern-di-typer ``` ```bash poetry add modern-di-typer ``` ### 2. Apply to your application ```python import dataclasses import typing import modern_di_typer import typer from modern_di import Container, Group, Scope, providers @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class Settings: service_name: str = "catalog" @dataclasses.dataclass(kw_only=True, slots=True) class Report: settings: Settings # APP-scoped, injected by type def render(self) -> str: return f"service={self.settings.service_name}" class AppGroup(Group): settings = providers.Factory(Settings, scope=Scope.APP, cache=True) report = providers.Factory(Report, scope=Scope.REQUEST) app = typer.Typer() container = Container(groups=[AppGroup]) modern_di_typer.setup_di(app, container) container.validate() # fails fast on a broken graph before the CLI runs @app.command() @modern_di_typer.inject def status( report: typing.Annotated[Report, modern_di_typer.FromDI(Report)], # resolve by type ) -> None: typer.echo(report.render()) if __name__ == "__main__": with container: # runs APP-scope finalizers on exit app() ``` `@modern_di_typer.inject` builds a `REQUEST` child container for each command invocation and resolves `FromDI`-annotated parameters from it. The outer `with container:` ensures APP-scope finalizers run when the CLI exits. ## Action scope To resolve `Scope.ACTION` dependencies, inject `modern_di.Container` — `@inject` supplies the `REQUEST`-scoped container it creates per invocation. Call `build_child_container()` on it to enter `ACTION` scope: Building on the first example's `app` and `container`: ```python import modern_di import modern_di_typer import typing from modern_di import Group, Scope, providers class Job: def run(self) -> None: ... class AppGroup(Group): job = providers.Factory(Job, scope=Scope.ACTION, bound_type=None) @app.command() @modern_di_typer.inject def run_job( container: typing.Annotated[modern_di.Container, modern_di_typer.FromDI(modern_di.Container)], ) -> None: with container.build_child_container() as action_container: job = action_container.resolve_provider(AppGroup.job) job.run() ``` ## See also - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — swap providers in your tests. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and container teardown. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the APP → REQUEST lifetime model. ## API | Symbol | Description | | -------------------------- | ----------------------------------------------------------------------------------- | | `setup_di(app, container)` | Register the app-scoped container with a Typer app | | `@inject` | Decorator that resolves `FromDI`-annotated parameters before the command runs | | `FromDI(provider_or_type)` | Marker for `Annotated[T, FromDI(...)]`; accepts a provider instance or a plain type | | `fetch_di_container(ctx)` | Returns the app-scoped container from `ctx.obj` | # 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. 1. **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. 1. **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](https://modern-di.modern-python.org/providers/scopes/index.md) 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. ```python 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. ```python 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. ```python 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 `yield`s 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: ```python 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](#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](#frameworks-without-native-di-the-decorator-path). 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: ```python 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](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule): | 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`](https://modern-di.modern-python.org/integrations/starlette/index.md)) 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](#frameworks-without-native-di-the-decorator-path). The **aiohttp** integration ([`modern-di-aiohttp`](https://modern-di.modern-python.org/integrations/aiohttp/index.md)) 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`](https://modern-di.modern-python.org/integrations/pytest/index.md)) 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`](https://modern-di.modern-python.org/integrations/typer/index.md)'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`](https://modern-di.modern-python.org/integrations/starlette/index.md) 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. ```python 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-`; import package `modern_di_`. - **Layout.** - `modern_di_/main.py` — the entire implementation. - `modern_di_/__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-"`, `description = "modern-di integration for "`, dependencies `[">=...,<...", "modern-di>=,<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/.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** `AGENTS.md` and `justfile`. Keep behavioural invariants in named tests rather than in a prose truth home, and record rejected alternatives as ADRs under `docs/adr/`. 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/.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](#lifecycle-rules) 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](https://modern-di.modern-python.org/integrations/grpc/index.md) uses and [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects) documents. Follow the example with any framework-specific sections, a tailored `## See also` block linking [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md), [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md), [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md), and the most relevant recipe, and the `## API` table last. Integrations do not ship their own docs site. - **Release.** Tag-driven, mirroring `modern-di`: push a bare semver tag off green `main` and let the workflow publish. ## Checklist - [ ] Repo `modern-di-`, package `modern_di_`, `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](#frameworks-without-native-di-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. - [ ] `AGENTS.md` and `justfile` mirrored; invariants pinned by named tests. # Recipes # Async SQLAlchemy: engine, session, repository **Problem.** Wire `create_async_engine` + `AsyncSession` + repository classes through `modern-di` so the engine is shared process-wide, sessions are per-request, and cleanup happens automatically at shutdown and at the end of each request. ## Solution Three providers, three scopes: - **Engine** at `Scope.APP` — one per process, cached, disposed at shutdown. - **Session** at `Scope.REQUEST` — one per request, cached inside that request, closed at the end of the request. - **Repositories** at `Scope.REQUEST` — depend on the session by type; one per request. ```python import sqlalchemy.ext.asyncio as sa_async from modern_di import Group, Scope, providers def create_engine() -> sa_async.AsyncEngine: return sa_async.create_async_engine( "postgresql+asyncpg://user:pass@localhost/db", pool_pre_ping=True, ) async def close_engine(engine: sa_async.AsyncEngine) -> None: await engine.dispose() def create_session(engine: sa_async.AsyncEngine) -> sa_async.AsyncSession: return sa_async.AsyncSession(engine, expire_on_commit=False) async def close_session(session: sa_async.AsyncSession) -> None: await session.close() class UserRepository: def __init__(self, session: sa_async.AsyncSession) -> None: self.session = session class Dependencies(Group): engine = providers.Factory( create_engine, scope=Scope.APP, cache=providers.CacheSettings(finalizer=close_engine), ) session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) user_repository = providers.Factory( UserRepository, scope=Scope.REQUEST, ) ``` The session factory consumes `engine: sa_async.AsyncEngine` via type-based wiring — no `kwargs={}` needed. `UserRepository` consumes `session: sa_async.AsyncSession` the same way. Wire to your framework as usual: ```python import fastapi import modern_di_fastapi from modern_di import Container container = Container(groups=[Dependencies]) app = fastapi.FastAPI() modern_di_fastapi.setup_di(app, container) ``` The integration creates a REQUEST child container per request, so the session and repository are created on first resolve and cleaned up when the request ends. ## Pitfalls - **`CacheSettings.finalizer` accepts sync or async functions** — it auto-detects. Don't wrap with `asyncio.run` or `asyncio.ensure_future`. - **`expire_on_commit=False`** on `AsyncSession` avoids expensive refreshes after commit. If you rely on `expire_on_commit=True`, leave it — but it's a common source of "session is closed" errors in async code. - **Don't share the engine across REQUEST containers manually.** The provider already does it: REQUEST containers walk up to the APP container to resolve the engine. - **Repositories must be REQUEST-scoped**, not APP-scoped — they hold a session which is REQUEST-scoped, and `container.validate()` will reject the inverse. ## Variations - **Multiple databases.** Declare two engine factories, two session factories, and give the second set distinct return types or `bound_type=` arguments so type-based resolution can tell them apart. - **Test connections.** Tests typically override the engine with an `AsyncConnection` inside a transaction — see [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md). ## See also - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — finalizers and `close_async()`. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — why the engine is APP and sessions are REQUEST. - [Litestar integration](https://modern-di.modern-python.org/integrations/litestar/index.md), [FastAPI integration](https://modern-di.modern-python.org/integrations/fastapi/index.md). - Reference templates: [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template), [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template). # Async resources via lifespan **Problem.** A resource genuinely needs an `await` (or a running event loop) to construct — `aiohttp.ClientSession`, an `asyncpg` connection pool, an authenticated client whose construction does a token exchange. `modern-di` resolves synchronously, so the construction has to happen outside the resolve path. ## Solution Do the async construction in the framework's lifespan. Use `container.set_context(SomeType, instance)` to register the live object on the APP container, then declare a `ContextProvider(SomeType, scope=Scope.APP)` so downstream factories can depend on the type. ```python import contextlib from collections.abc import AsyncIterator import aiohttp import fastapi from modern_di import Container, Group, Scope, providers class Dependencies(Group): http_client = providers.ContextProvider( aiohttp.ClientSession, scope=Scope.APP, ) # Downstream factories declare `client: aiohttp.ClientSession` and get the live instance weather_api = providers.Factory( WeatherApi, # signature: (client: aiohttp.ClientSession) scope=Scope.REQUEST, ) container = Container(groups=[Dependencies]) @contextlib.asynccontextmanager async def lifespan(app: fastapi.FastAPI) -> AsyncIterator[None]: async with container: # ensures close_async on exit async with aiohttp.ClientSession() as session: # must be inside running loop container.set_context(aiohttp.ClientSession, session) yield # ClientSession is closed by `async with` here app = fastapi.FastAPI(lifespan=lifespan) ``` `aiohttp.ClientSession` captures the running event loop at construction time, so it has to be built inside an async context — which the lifespan provides. The same pattern works for `asyncpg.create_pool(...)` (truly async), authenticated API clients that do a token exchange at startup, or anything else that needs `await` to be ready. `asyncpg.create_pool(...)` returns an awaitable `Pool` that only opens its connections when `await`ed (or entered with `async with`); modern-di has async *finalizers* but no async *initializer*, so the `await` has to happen in the lifespan. ## Pitfalls - **Set context *before* yielding.** The lifespan hands control to the app inside the `yield`. If you `set_context` after yielding, requests that arrive in between won't see the value. - **`set_context` never propagates between containers** — see [context propagation](https://modern-di.modern-python.org/providers/context/#context-propagation). In the lifespan pattern above this is fine — the resource is APP-scoped, so the APP-scoped `ContextProvider` reads the value set on the APP container; per-request context is passed to each REQUEST child via `build_child_container(context={...})`. - **Combining a hand-written lifespan with an integration's `setup_di`.** The integration (e.g. [`modern-di-fastapi`](https://modern-di.modern-python.org/integrations/fastapi/index.md)'s `setup_di(app, container)`) already appends a lifespan that closes the container, and it merges with any `lifespan=` you pass. Keep the resource setup in your lifespan but drop the `async with container` wrapper — the integration owns the container close, and wrapping both closes it twice. - **Choose APP scope unless the resource is per-connection.** Redis/Kafka clients are process-singletons. For per-websocket-session resources, use `Scope.SESSION`. - **`async with container:` handles APP-scope finalizers.** If you also registered a `CacheSettings(finalizer=...)` somewhere, this runs it on exit. The lifespan-managed object isn't wrapped by a Factory, so its cleanup (`async with aiohttp.ClientSession()` in the example) is on you. ## When a sync creator works instead Many "async" resources actually construct synchronously — `redis.asyncio.Redis.from_url(...)`, `sqlalchemy.ext.asyncio.create_async_engine(...)`, and `httpx.AsyncClient(...)` all return without awaiting. For those, prefer a normal `Factory` with `cache=CacheSettings(finalizer=async_close_fn)` and skip the lifespan + `set_context` dance entirely. Use this recipe only when construction genuinely needs `await` or a running event loop. ## See also - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — `close_async()` and finalizers. - [Context Provider](https://modern-di.modern-python.org/providers/context/index.md) — `ContextProvider` and `set_context` in depth. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — APP vs SESSION vs REQUEST. - [Async SQLAlchemy recipe](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — the sync-creator-with-async-finalizer pattern for comparison. # Organize a large container with multiple Groups **Problem.** Your service has 30+ providers and stuffing them all into one `Group` is unreadable. ## Solution Split providers into multiple `Group` subclasses by domain — database, cache, messaging, use cases — and pass them all to `Container(groups=[...])`. Cross-group dependencies wire by type, with no explicit references between groups. ```python import redis.asyncio as aioredis import sqlalchemy.ext.asyncio as sa_async from modern_di import Container, Group, Scope, providers # --- factory functions (defined once, shared across groups) --- def create_engine() -> sa_async.AsyncEngine: return sa_async.create_async_engine("postgresql+asyncpg://localhost/app") async def close_engine(engine: sa_async.AsyncEngine) -> None: await engine.dispose() def create_session(engine: sa_async.AsyncEngine) -> sa_async.AsyncSession: return sa_async.AsyncSession(engine, expire_on_commit=False) async def close_session(session: sa_async.AsyncSession) -> None: await session.close() def create_redis() -> aioredis.Redis: return aioredis.Redis.from_url("redis://localhost") async def close_redis(client: aioredis.Redis) -> None: await client.aclose() # --- groups --- class Database(Group): engine = providers.Factory( create_engine, scope=Scope.APP, cache=providers.CacheSettings(finalizer=close_engine), ) session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) class Cache(Group): redis_client = providers.Factory( create_redis, scope=Scope.APP, cache=providers.CacheSettings(finalizer=close_redis), ) class Repositories(Group): # UserRepository signature: (session: AsyncSession) users = providers.Factory(UserRepository, scope=Scope.REQUEST) orders = providers.Factory(OrderRepository, scope=Scope.REQUEST) class UseCases(Group): # PlaceOrder signature: (users: UserRepository, orders: OrderRepository, cache: aioredis.Redis) place_order = providers.Factory(PlaceOrder, scope=Scope.REQUEST) cancel_order = providers.Factory(CancelOrder, scope=Scope.REQUEST) ALL_GROUPS = [Database, Cache, Repositories, UseCases] container = Container(groups=ALL_GROUPS) ``` `PlaceOrder` depends on providers from three different groups — `Repositories`, `Cache`, `Database` (transitively via the repositories). Nothing in `UseCases` references the other groups directly; type-based wiring sorts it out. ## Pitfalls - **Duplicate `bound_type` raises at container creation.** If two groups register providers for the same type (e.g. both bind to `AsyncSession`), `Container(groups=[...])` raises `DuplicateProviderTypeError` immediately. Fix by assigning distinct types — e.g. declare thin subclasses (`class WriteSession(AsyncSession): ...`) — or set `bound_type=None` on one provider and wire it explicitly via `kwargs`. See [Duplicate provider type](https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/index.md). - **Attribute-name collisions do not affect `Container`.** `Container` keys providers on their `bound_type`, not on the attribute name. Two groups can both have an attribute named `session` as long as their `bound_type`s differ — `Container` sees no conflict. The duplicate-name `ValueError` belongs to `modern-di-pytest`'s `expose(*groups)` helper (a separate package), which generates one pytest fixture per attribute name and does raise `ValueError` on duplicates. If you use `expose()`, ensure attribute names are unique across the groups you pass to it. - **Order in `groups=[...]` does not matter for resolution.** Validate at startup by calling `container.validate()` explicitly — nothing runs the check for you. ## Auto-wiring with Litestar If you're on Litestar, pass `autowired_groups=ALL_GROUPS` to `ModernDIPlugin` and every provider in those groups is automatically registered as a Litestar dependency by attribute name. Handlers can then declare `place_order: PlaceOrder` as a plain parameter — no per-route `FromDI`. ```python from modern_di_litestar import ModernDIPlugin app = Litestar( plugins=[ModernDIPlugin(container, autowired_groups=ALL_GROUPS)], ) ``` See the [Litestar integration](https://modern-di.modern-python.org/integrations/litestar/index.md) for the full pattern. ## See also - [Factories](https://modern-di.modern-python.org/providers/factories/index.md), [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md). - [Litestar integration](https://modern-di.modern-python.org/integrations/litestar/index.md) — `autowired_groups`. - [Async SQLAlchemy recipe](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — the building blocks for the `Database` group above. # Testing with overrides **Problem.** Tests need to swap a real dependency (database, HTTP client, clock) for a fake one without touching production wiring. ## Solution `container.override(provider, replacement)` replaces what the provider resolves to, immediately, and returns an `OverrideHandle`. Used as a context manager, it auto-resets on exit — this is the primary spelling for tests: ```python with container.override(MyGroup.api_client, mock_client) as client: ... # resolution returns mock_client; prior state restored on exit ``` The override applies at the `override()` call, not at `__enter__`. `__exit__` restores the snapshot taken at that call — a previously stacked override if there was one, otherwise no override — even on exception, and even if `reset_override()` — or a root `close_sync()`/`close_async()`, which clears all overrides — ran inside the block; exit still restores the snapshot. Nested overrides of the same provider unwind in order: each handle restores whatever was active before it. Handles are expected to exit in reverse order of creation — `with`-block nesting does this naturally; manually exiting handles out of order can restore stale state. `container.override(provider, replacement)` also works as a plain imperative call: reset with `container.reset_override(provider)` (or `container.reset_override()` to clear all). This pair remains fully supported — see the patterns below — and `close_sync`/`close_async` on the root container also clear all overrides automatically. Either way, the replacement is keyed by **provider reference** (not name) and is shared across the container tree, so an override on the root APP container applies to all child REQUEST containers too. ## Pattern 1: Simple mock override For unit-style tests, override the provider with a fake before exercising the code under test: ```python from unittest.mock import AsyncMock import pytest from app.ioc import Dependencies, container @pytest.fixture def fake_users() -> AsyncMock: fake = AsyncMock(spec=UserRepository) container.override(Dependencies.user_repository, fake) yield fake container.reset_override(Dependencies.user_repository) async def test_place_order_calls_users(fake_users: AsyncMock) -> None: use_case = container.resolve(PlaceOrder) await use_case.run(...) fake_users.find_by_id.assert_awaited() ``` ## Pattern 2: Transactional session fixture (real database) For integration tests against a real database, run each test in a nested transaction that rolls back at the end. Override the engine provider with the test connection so every session created during the test reuses it. ```python import pytest import sqlalchemy.ext.asyncio as sa_async from app.ioc import Dependencies, container @pytest.fixture(scope="session") async def engine() -> sa_async.AsyncEngine: eng = sa_async.create_async_engine("postgresql+asyncpg://...test") try: yield eng finally: await eng.dispose() @pytest.fixture async def db_connection(engine: sa_async.AsyncEngine) -> sa_async.AsyncConnection: async with engine.connect() as connection: transaction = await connection.begin() container.override(Dependencies.engine, connection) try: yield connection finally: container.reset_override(Dependencies.engine) await transaction.rollback() ``` Tests that pull a session through DI (`container.resolve(sa_async.AsyncSession)`) get one bound to the test connection, and everything they write rolls back at the end. ## Pattern 3: `modern-di-pytest` fixtures For tests that consume DI dependencies as fixtures rather than resolving manually, the `modern-di-pytest` package generates fixtures from providers: ```python from modern_di_pytest import expose, modern_di_fixture from app.ioc import Dependencies # Single fixture from a specific provider user_repository = modern_di_fixture(Dependencies.user_repository) # Or expose every provider in a Group as a fixture (one per attribute) expose(Dependencies) async def test_user_repo(user_repository: UserRepository) -> None: assert await user_repository.count() == 0 ``` Combine with `container.override(...)` in a setup fixture to swap underlying providers — `modern_di_fixture` resolves through the override. ## Pitfalls - **Overrides are global.** Override the root APP container and every child REQUEST container sees the replacement. Fine in tests; remember it if you also override in production code. - **`override` is keyed by provider reference.** Pass `Dependencies.user_repository` (the provider object), not the string `"user_repository"`. - **Always `reset_override` in the fixture teardown.** Leaking overrides between tests is a class of bug that doesn't fail loudly. - **Wrap session-scoped containers in a function-scoped override fixture.** If the `Container` fixture itself is session-scoped (built once for the whole test run), don't call `override`/`reset_override` directly in a test — wrap the pair in their own function-scoped fixture so the override is guaranteed to reset after each test, even on failure. - **Override the right level.** If you override the engine but tests resolve the session, the session's creator still runs — make sure the engine override produces something the creator can use. If the test relies on a specific session, override the session directly. ## See also - [Pytest integration](https://modern-di.modern-python.org/integrations/pytest/index.md). - [Async SQLAlchemy recipe](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — the engine/session/repository chain being overridden here. - Reference template: [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template) — full transactional fixture setup. # Request-scoped engine selection (read replicas) > **Advanced.** Use this only if you have actual read-replica traffic to route. For a single-database setup, the [Async SQLAlchemy recipe](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) is what you want. **Problem.** Route read-only requests (`GET`, `HEAD`) to a read-replica engine and mutating requests to the primary, without changing handler code. ## Solution Two APP-scoped engine factories — primary and replica — and one REQUEST-scoped factory that inspects the request and returns the engine to use for it. Sessions and repositories depend on the *request-scoped* engine, not the named factories. ```python import sqlalchemy.ext.asyncio as sa_async import fastapi from modern_di import Group, Scope, providers def create_primary_engine() -> sa_async.AsyncEngine: return sa_async.create_async_engine("postgresql+asyncpg://primary/db") def create_replica_engine() -> sa_async.AsyncEngine: return sa_async.create_async_engine("postgresql+asyncpg://replica/db") async def close_engine(engine: sa_async.AsyncEngine) -> None: await engine.dispose() # Choose which engine this request uses. # `primary` and `replica` are injected by name from kwargs. # `request` is injected by type from the framework's request ContextProvider. def choose_engine( primary: sa_async.AsyncEngine, replica: sa_async.AsyncEngine, request: fastapi.Request, ) -> sa_async.AsyncEngine: if request.method in ("GET", "HEAD"): return replica return primary class PrimaryEngine(sa_async.AsyncEngine): ... class ReplicaEngine(sa_async.AsyncEngine): ... class Dependencies(Group): primary = providers.Factory( create_primary_engine, scope=Scope.APP, bound_type=PrimaryEngine, cache=providers.CacheSettings(finalizer=close_engine), ) replica = providers.Factory( create_replica_engine, scope=Scope.APP, bound_type=ReplicaEngine, cache=providers.CacheSettings(finalizer=close_engine), ) # REQUEST-scope: picks per-request, cached for the rest of that request engine = providers.Factory( choose_engine, scope=Scope.REQUEST, kwargs={"primary": primary, "replica": replica}, cache=True, ) # Sessions and repositories use the REQUEST-scoped engine session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) ``` Why the `PrimaryEngine` / `ReplicaEngine` subclasses: type-based resolution needs distinct types for the two factories. Without them, both would register under `AsyncEngine` and `Container(groups=[...])` would raise `DuplicateProviderTypeError` at startup. See [Duplicate provider type](https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/index.md). ## Pitfalls - **The choice factory must be REQUEST-scoped.** It depends on the per-request `Request` object — an APP-scoped factory cannot consume request-scoped data and `container.validate()` will reject it. - **The framework integration provides `fastapi.Request` (or `litestar.Request`) automatically.** No need to declare a `ContextProvider` for it. For Litestar, use `litestar.Request`. - **Don't apply this to per-connection pooling decisions.** Engines (and their pools) are APP-scoped — the choice you make per request just selects which long-lived pool the session checks out from. Trying to make the engine itself REQUEST-scoped would create and dispose a pool every request. - **Watch for write-after-read in a single request.** If a `GET` handler ends up doing a write (e.g. updating a `last_seen_at` field), it'll go to the replica and fail. Either move the side-effect out of the read path, or pick a different routing predicate than HTTP method. ## See also - [Async SQLAlchemy recipe](https://modern-di.modern-python.org/recipes/sqlalchemy/index.md) — the simpler single-engine pattern. - [Context Provider](https://modern-di.modern-python.org/providers/context/index.md) — how `Request` is injected. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — why the engines are APP but the choice is REQUEST. # Good and bad practices modern-di's docs mostly show the happy path. This page collects the footguns instead — real mistakes the framework lets you make, each paired with the mechanism that catches or prevents it. ## 1. Captive dependency: a wide-scoped provider holding a narrow-scoped one A *captive dependency* is a wide-scoped provider holding a narrow-scoped one it cannot actually outlive — see [the scope dependency rule](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) for why. ```python class Dependencies(Group): session = providers.Factory(Session, scope=Scope.REQUEST) # ❌ forgot scope=Scope.REQUEST — defaults to Scope.APP, which cannot hold `session` user_cache = providers.Factory(UserCache) # ✅ matches the lifetime of what it consumes user_cache = providers.Factory(UserCache, scope=Scope.REQUEST) ``` **Caught by:** an explicit `container.validate()` call, which raises `ValidationFailedError` carrying an `InvalidScopeDependencyError` for this exact graph before anything is ever resolved — see [Scope chain violation](https://modern-di.modern-python.org/troubleshooting/scope-chain/index.md). Nothing validates automatically, so if the graph is never validated, the runtime failure is a `ScopeNotInitializedError`/`ScopeSkippedError` that (since the scope-error breadcrumb work) now names both the provider that captured the dependency and the one that actually failed — but it fires on the first request that hits it, not at startup. Prefer catching it statically with an explicit `validate()` call. ## 2. Shipping a never-validated graph `validate()` is the only thing that checks the *whole* graph — cycles, inverted scopes, and missing dependencies. Nothing calls it for you: not construction, not `open()`, not `add_providers`, not `resolve()`. Skipping it doesn't remove the bugs, it just delays finding them to whichever resolve happens to hit one first. ```python # ❌ never validated: wiring bugs surface one at a time, in production, on whatever request trips them container = Container(groups=[Dependencies]) # ✅ validated explicitly: every wiring bug is reported at once, at startup container = Container(groups=[Dependencies]) container.validate() # raises ValidationFailedError here if the graph is broken ``` **Caught by:** an explicit `container.validate()` call — it is the only thing that finds every issue in the graph up front; without it, each wiring bug surfaces individually, at whichever resolve first reaches it. `Container(validate=...)` is deprecated and does nothing (see [Migration: To 3.x](https://modern-di.modern-python.org/migration/to-3.x/#4-validate-runs-at-container-entry-on-by-default)). An unvalidated cyclic graph still isn't a silent hang — see [the runtime cycle guard](https://modern-di.modern-python.org/troubleshooting/circular-dependency/#the-runtime-cycle-guard-without-validate). ## 3. A cached factory resolved before `set_context` Context values are read live on every resolve of a **non-cached** factory — but a **cached** factory is built once, and a later `set_context` does not rebuild it. ```python class Dependencies(Group): tenant_id = providers.ContextProvider(str, scope=Scope.REQUEST) # ❌ cached: built on first resolve and frozen from then on tenant_config = providers.Factory(create_tenant_config, scope=Scope.REQUEST, cache=True) # ✅ uncached: re-reads the live context on every resolve tenant_config = providers.Factory(create_tenant_config, scope=Scope.REQUEST) ``` If a request container resolves `tenant_config` before the real tenant ID is known (e.g. during setup), the cached version keeps serving that first value for the rest of the request even after `request.set_context(str, real_tenant_id)` runs. Either drop `cache=True` for anything whose correctness depends on context set later, or make sure `set_context` runs before the first resolve. **Caught by:** nothing automatic — this is a timing bug, not a wiring bug, so `validate()` cannot see it. See [Context propagation](https://modern-di.modern-python.org/providers/context/#context-propagation) for how `set_context` timing interacts with a provider's scope, and [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) for caching. ## 4. Service location via `container_provider` overuse `container_provider` lets a creator accept the resolving `Container` itself and pull dependencies out of it manually. Used for its intended purpose (a provider that genuinely needs the container, such as building a child container), it's fine. Used as a shortcut to avoid declaring real parameters, it turns type-driven DI into a service locator: the dependency is hidden from `validate()`, from readers, and from anyone trying to see the graph. ```python # ❌ the real dependency (Settings) is invisible to validate() and to the signature def create_api_key(container: Container) -> str: return container.resolve(Settings).api_key # ✅ declared as an ordinary parameter — visible, validated, and testable via override def create_api_key(settings: Settings) -> str: return settings.api_key ``` **Caught by:** nothing enforces this — it's a style discipline, not a validation rule. Reserve `container_provider` for cases that are actually about the container (building a child container, introspecting the current scope), and declare everything else as a typed parameter so `validate()` and [Resolving dependencies](https://modern-di.modern-python.org/introduction/resolving/index.md) can see it. ## 5. Override leaks across tests `container.override(provider, replacement)` replacements are shared across the *whole* container tree — see [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) for the mechanics. Forgetting to reset it doesn't just affect the test that set it — every later test that shares the container inherits the replacement. ```python # ❌ no reset: the next test that resolves Clock silently gets the fake def test_one() -> None: container.override(Dependencies.clock, fake_clock) ... # ✅ always reset, even if the test fails — a fixture teardown is the reliable place for this @pytest.fixture def frozen_clock() -> Mock: fake = Mock(spec=Clock) container.override(Dependencies.clock, fake) yield fake container.reset_override(Dependencies.clock) ``` **Caught by:** nothing automatic mid-suite — `reset_override(provider)` (or `reset_override()` with no arguments, to clear everything) is the fix, and closing the **root** container clears every override in the shared registry as a last resort. See [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/#pitfalls). ## 6. `skip_creator_parsing=True` with no `bound_type` `skip_creator_parsing=True` turns off signature introspection — useful for callables that can't be reflected (C extensions, `functools.partial`). But skipping introspection also means modern-di has no idea what type the provider produces, so type-based resolution silently can't find it. ```python # ❌ nothing else can resolve this provider by type — UserWarning at declaration time providers.Factory(opaque_creator, scope=Scope.APP, skip_creator_parsing=True) # ✅ tell modern-di the type explicitly providers.Factory( opaque_creator, scope=Scope.APP, skip_creator_parsing=True, bound_type=MyClass, ) ``` **Caught by:** a `UserWarning` at declaration time. It's easy to miss in test output — treat it as a signal to add `bound_type=`, not to ignore. ## See also - [Errors and exceptions](https://modern-di.modern-python.org/providers/errors-and-exceptions/index.md) — the full catalog this page draws its mechanisms from. - [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) — the full override lifecycle. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — caching, finalizers, and `validate()`. # Troubleshooting # InvalidChildScopeError **Symptom** Raised from `Container(...)` or `build_child_container(scope=...)`, naming the parent scope, the requested child scope, and the list of scopes that would have been accepted. **Cause** A child's scope must be strictly deeper (a higher `IntEnum` value) than the parent's. Passing an explicit `scope=` that is equal to the parent's (e.g. `Scope.SESSION` from a `SESSION` parent) or shallower (e.g. `Scope.APP` from a `SESSION` parent) raises this error. **Fix** Pass a scope whose value is strictly greater than the parent's: ```python from modern_di import Scope app_container = Container(scope=Scope.APP, groups=[MyGroup]) # Wrong: SESSION is not deeper than SESSION mid = app_container.build_child_container(scope=Scope.SESSION) bad = mid.build_child_container(scope=Scope.SESSION) # raises InvalidChildScopeError # Right good = mid.build_child_container(scope=Scope.REQUEST) ``` **Escape hatches** Omit `scope=` entirely — `build_child_container()` derives the next deeper scope automatically, so this error can only occur when you explicitly pin a scope value. Inspect `.allowed_scopes` on the caught exception for the exact list of valid choices at that point in the tree. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) — the scope hierarchy and ordering rule. # MaxScopeReachedError **Symptom** Raised from `build_child_container()` called with no explicit `scope=` argument, naming the parent scope that has no deeper scope to advance to. **Cause** `build_child_container()` without an explicit `scope=` auto-derives the next deeper scope by picking the smallest enum member greater than the parent's. The built-in `Scope` enum ends at `STEP`; calling `build_child_container()` on a `STEP`-scope container has nowhere further to go. **Fix** Define a custom `IntEnum` scope with a member deeper than `STEP` and build the child with that scope explicitly: ```python import enum from modern_di import Scope class ExtendedScope(enum.IntEnum): APP = Scope.APP SESSION = Scope.SESSION REQUEST = Scope.REQUEST ACTION = Scope.ACTION STEP = Scope.STEP SUBSTEP = 6 step_container = Container(scope=ExtendedScope.STEP, parent_container=action_container) sub_container = step_container.build_child_container(scope=ExtendedScope.SUBSTEP) ``` Root containers rarely need this — reconsider whether the provider actually needs a scope deeper than `STEP`, or whether it belongs at an existing shallower scope instead. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the built-in hierarchy and how to extend it with a custom `IntEnum`. # ScopeNotInitializedError **Symptom** A resolution fails naming a provider's scope and the current container's scope, optionally with a dependency-path breadcrumb when the failing provider was captured by a shallower one. Each breadcrumb line may end with a pointer to where that provider was declared (module and line number), so you can jump straight to the declaration. **Cause** A provider's scope is deeper than any container currently in the chain — you resolved (directly or transitively) a provider whose scope has no matching container built yet. For example, a `REQUEST`-scoped provider resolved straight from the `APP` container, with no `REQUEST` child ever built. **Fix** Build the deeper-scoped container before resolving from it: ```python app_container = Container(scope=Scope.APP, groups=[MyGroup]) # Wrong: no REQUEST container exists yet app_container.resolve(RequestScopedThing) # raises ScopeNotInitializedError # Right request_container = app_container.build_child_container(scope=Scope.REQUEST) request_container.resolve(RequestScopedThing) ``` When the breadcrumb shows a captive dependency (a shallower provider depending on this deeper one), the real fix is usually to move the *depending* provider to the deeper scope instead — see the scope dependency rule below, which `validate()` catches ahead of time as `InvalidScopeDependencyError`. ## See also - [Scope chain violation](https://modern-di.modern-python.org/troubleshooting/scope-chain/index.md) — the related, statically-detected form of this problem. - [Scopes: the scope dependency rule](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule). # ScopeSkippedError **Symptom** A resolution fails naming a provider's scope and the current container's scope, optionally with a dependency-path breadcrumb — the requested scope is shallower than the current container, but no container at that scope exists anywhere in this chain. Each breadcrumb line may end with a pointer to where that provider was declared (module and line number), so you can jump straight to the declaration. **Cause** The container chain skipped an intermediate scope when it was built. For example, a chain built `APP → ACTION` (skipping `SESSION` and `REQUEST` entirely) has no `REQUEST` container to satisfy a `REQUEST`-scoped provider, even though `REQUEST` is shallower than the current `ACTION` container. **Fix** Build child containers through every intermediate scope your providers need, rather than jumping straight to a deep one: ```python app_container = Container(scope=Scope.APP, groups=[MyGroup]) # Wrong: jumps straight past REQUEST action_container = app_container.build_child_container(scope=Scope.ACTION) action_container.resolve(RequestScopedThing) # raises ScopeSkippedError # Right: build through REQUEST first request_container = app_container.build_child_container(scope=Scope.REQUEST) action_container = request_container.build_child_container(scope=Scope.ACTION) action_container.resolve(RequestScopedThing) ``` If a framework integration builds the chain for you, check which scopes it actually instantiates per request/message and align your providers to those, not to the full built-in hierarchy. ## See also - [Scope chain violation](https://modern-di.modern-python.org/troubleshooting/scope-chain/index.md) — the related, statically-detected form of this problem. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — how container chains map to the scope hierarchy. # InvalidScopeTypeError **Symptom** Raised when constructing a `Container` or when defining a `Group` subclass with a `scope=` class kwarg, naming the value that was passed as `scope=` and its type. **Cause** `scope=` must be an `enum.IntEnum` member. This fires in two contexts: 1. When passed to the `Container` constructor — when a plain `int`, a string, a regular `enum.Enum` (not `IntEnum`), or any other non-`IntEnum` value is used. 1. When passed to a `Group` subclass as a class kwarg — same validation applies. Example invalid uses: `Container(scope=1)`, `Container(scope="APP")`, `class MyGroup(Group, scope=1)`, `class MyGroup(Group, scope="REQUEST")`. **Fix** Use the built-in `Scope` enum, or your own `IntEnum` subclass: ```python from modern_di import Container, Scope # Wrong container = Container(scope=1) # raises InvalidScopeTypeError container = Container(scope="APP") # raises InvalidScopeTypeError # Right container = Container(scope=Scope.APP) ``` If you need scopes beyond the five built-in ones, define your own `enum.IntEnum` whose members' values are ordered the way you want the hierarchy to resolve, and use that instead of `Scope`. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the `IntEnum` hierarchy and why membership is required. # ContainerClosedError **No longer raised.** As of modern-di 3.1, a container is usable immediately after construction — there is no unopened state that raises. This page stays (every concrete `modern-di` error keeps a troubleshooting page) to document the class's back-compat status and the warning that replaced its failure mode. **What changed** Through 3.0, resolving from (or building a child of) a container that had never been opened, or one closed after use, raised `ContainerClosedError`. As of 3.1: - A container is **open from construction** — `closed = False` the moment `Container(...)` returns, with no `open()` step required and nothing to raise. `build_child_container()` never checks or touches any container's open/closed state — it only reads the parent's shared registries and scope map — and the child it returns starts open too, same as any freshly-constructed container. - Reusing a container **after an explicit close** — `close_sync()`, `close_async()`, or exiting a `with`/`async with` block — self-heals the moment the container is actually resolved from, either directly or through a descendant whose resolve reaches back into its scope: the container reopens and the call succeeds, but it first emits `ContainerClosedWarning`, a `RuntimeWarning` carrying `.container_scope`. Building a child of that closed container does not, on its own, trigger any of this. - `open()` stays available as the *explicit*, silent way to reopen a closed container — call it (or re-enter via `with`/`async with`) when the reuse is deliberate, so no warning fires. It runs no validation of its own; call `container.validate()` separately for a fail-fast check. `ContainerClosedError` itself is kept importable for 3.x back-compat — an `except exceptions.ContainerClosedError` clause does not break at import time — but nothing in the library raises it anymore. It is removed in 4.0. **What `ContainerClosedWarning` means** Seeing it means a reference to an already-closed container was resolved from — directly, or through a child container whose resolve reached back into the closed container's scope — without going back through `open()`/`with` first. Two ways to respond: - **Deliberate reuse** (e.g. a test harness or a callback-style lifecycle that closes and later restarts the same container object): call `container.open()`, or re-enter it with `with`/`async with`, before the next use — that reopens silently, with no warning, since a deliberate reopen is not diagnostic-worthy. - **Unintentional reuse**: the warning is telling you a reference to the container is being held past its lifetime — e.g. a request handler cached the container from a previous unit of work instead of fetching a fresh one. Find where that reference is coming from and fix the leak instead of silencing the warning. To make either path fail loudly during development, escalate the warning to an error: ```python import warnings from modern_di import exceptions warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning) ``` This restores 3.0's strictness for reuse-after-close (the never-opened case still self-heals silently either way, since there is nothing to warn about there). ## See also - [Migration: To 3.x](https://modern-di.modern-python.org/migration/to-3.x/#1-closed-containers-raise-instead-of-self-healing) — the 3.0 behavior this page used to describe, and the 3.1 note relaxing it. - [Lifecycle: closing and reopening](https://modern-di.modern-python.org/providers/lifecycle/#closing-and-reopening). # ValidationFailedError **Symptom** Raised by `Container.validate()`, rendering a report grouped by error class name, with the count of each kind and every individual issue indented underneath. **Cause** The provider graph has one or more problems: a circular dependency, a provider depending on a deeper-scoped one, a creator parameter with no way to be resolved, or an alias whose source type has no registered provider. `validate()` collects **every** issue across the whole graph in one pass rather than stopping at the first one, so `.errors` (a `list[Exception]`) may hold several distinct exception types at once. **Fix** Inspect `.errors` to see every underlying issue, or read the grouped `str()` report directly — each group is one of `CircularDependencyError`, `InvalidScopeDependencyError`, `ArgumentResolutionError`, or `AliasSourceNotRegisteredError` today. Fix each one; their own pages cover the specific cause and remedy: ```python try: container.validate() except exceptions.ValidationFailedError as exc: for error in exc.errors: print(type(error).__name__, error) ``` Calling `validate()` explicitly at startup, before the first real request, is the whole point — it turns graph bugs into a single startup-time failure instead of scattered runtime surprises. Nothing calls it for you: not construction, not `open()`, not `resolve()`. ## See also - [Lifecycle: validation](https://modern-di.modern-python.org/providers/lifecycle/#validation). - [Circular dependency](https://modern-di.modern-python.org/troubleshooting/circular-dependency/index.md), [Scope chain violation](https://modern-di.modern-python.org/troubleshooting/scope-chain/index.md), [Argument resolution error](https://modern-di.modern-python.org/troubleshooting/argument-resolution-error/index.md), [Alias source not registered](https://modern-di.modern-python.org/troubleshooting/alias-source-not-registered-error/index.md) — the underlying issue kinds. # No provider registered for type This error fires when a creator parameter is typed `Foo` and the container has no registered provider for `Foo`. ## Understanding the error **Direct miss** — resolving an unregistered type directly: ```text ProviderNotRegisteredError: Provider of type is not registered in providers registry. ``` **Nested miss** — a registered factory whose creator depends on an unregistered type: ```text ArgumentResolutionError: Cannot resolve dependency chain: APP MyService caused by: Argument dep of type cannot be resolved. Trying to build dependency . ``` The resolver walked the creator's signature, found a parameter typed `MissingDep`, and looked it up in the providers registry — nothing was there. The "dependency chain" header shows where in the resolution graph the miss occurred. ## Common causes ### 1. The group containing the provider was not passed to `Container` Most common. If you split providers across `Database`, `UseCases`, `Cache`, you have to list them all: ```python container = Container(groups=[Database, UseCases, Cache]) container.validate() ``` Missing one group means none of its providers are registered. Calling `container.validate()` at startup catches this before the first request. ### 2. The creator has no return type annotation `modern-di` infers the provider's `bound_type` from the creator's return annotation. A creator like `def create_thing(...): ...` (no `-> SomeType`) has no inferable `bound_type` and won't be resolvable by type. ```python # ❌ Cannot resolve by type def create_engine(...): return sa_async.create_async_engine(...) # ✅ Return-typed def create_engine(...) -> sa_async.AsyncEngine: return sa_async.create_async_engine(...) ``` Fix: add the return annotation, or set `bound_type=SomeType` on the provider explicitly. ### 3. `bound_type=None` was set on the provider you want to resolve `bound_type=None` makes the provider unresolvable by type. It's a deliberate opt-out for cases where two providers return the same type (see [Duplicate Type Error](https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/index.md)). If you set it on the wrong provider, the type lookup misses. Fix: leave `bound_type` at its default on the provider you want resolvable by type. If both providers really do produce the same type, resolve the unresolvable one by reference (`container.resolve_provider(...)`). ### 4. The parameter is a union and the chosen branch isn't registered For `dep: A | B`, `modern-di` resolves the *first* type in the union order that has a registered provider. If neither is registered, the resolver fails. Fix: register a provider for one of the union types, or annotate the parameter with a concrete type. ## See also - [Resolving](https://modern-di.modern-python.org/introduction/resolving/index.md) — the by-type lookup algorithm. - [Duplicate Type Error](https://modern-di.modern-python.org/troubleshooting/duplicate-type-error/index.md) — the inverse problem, where two providers compete for the same type. - [Factories: `bound_type`](https://modern-di.modern-python.org/providers/factories/index.md) — how the bound type is inferred and how to override it. # AliasSourceNotRegisteredError **Symptom** Raised naming the `source_type` an `Alias` points at, saying no provider is registered for it. **Cause** `Alias(X)` was declared, but no provider's `bound_type` resolves to `X` — either the provider for `X` was never defined, its group wasn't passed to `Container(groups=[...])`, or it was declared with `bound_type=None` (making it unresolvable by type, which an alias also can't reach). This is checked eagerly during `validate()`, and again at resolve time if validation was skipped. **Fix** Register (and include) a provider for the source type before defining the alias: ```python from modern_di import Group, Scope, providers class Dependencies(Group): # The alias's source must resolve by type — no bound_type=None here. impl = providers.Factory(Implementation, scope=Scope.APP) interface_alias = providers.Alias(Implementation) ``` If the source provider lives in a different `Group`, make sure that group is also passed to `Container(groups=[...])`. Call `container.validate()` so this is caught at startup rather than on first resolve. ## See also - [Alias](https://modern-di.modern-python.org/providers/alias/index.md) — binding one type to an already-registered provider. - [No provider registered for type](https://modern-di.modern-python.org/troubleshooting/missing-provider/index.md) — the same "unregistered type" problem, without an alias in the way. # ArgumentResolutionError **Symptom** Raised naming a creator's parameter and the type it's annotated with, saying the argument couldn't be resolved while building a given dependency — often rendered as a dependency-chain trace with a `caused by:` line naming the specific parameter. **Cause** A creator parameter has no registered provider for its annotated type, no default value, and no matching `kwargs` entry — so `modern-di` has nothing to inject. This also covers an unannotated parameter with none of those escape routes, and a `ContextProvider`-backed parameter whose context value is unset and required (not optional, no default). **Fix** Pick whichever applies: register a provider for the missing type, give the parameter a default, or pass it explicitly via `kwargs`: ```python class Dependencies(Group): # missing: no provider for `Clock` anywhere service = providers.Factory(Service, scope=Scope.APP) # Service(clock: Clock) # fix option 1: register a provider clock = providers.Factory(SystemClock, scope=Scope.APP, bound_type=Clock) # fix option 2: pass explicitly service2 = providers.Factory(Service, scope=Scope.APP, kwargs={"clock": clock}) ``` **Integration-supplied context types.** If the missing type is one a framework integration provides at runtime (`fastapi.Request`, `taskiq.TaskiqMessage`, …), its `ContextProvider` is registered by `setup_di()` — so a `container.validate()` call made *before* `setup_di()` runs sees no provider for it yet and raises. Either call `validate()` **after** `setup_di()` (the provider is registered by then), or make the parameter optional (`request: fastapi.Request | None = None`) so validation skips it regardless of ordering; the integration still injects the real value at runtime either way. See [Framework Context Objects](https://modern-di.modern-python.org/providers/context/#framework-context-objects). Check `.suggestions` on the caught exception for a "did you mean" hint when a similarly-named type is registered instead. ## See also - [No provider registered for type](https://modern-di.modern-python.org/troubleshooting/missing-provider/index.md) — the direct-resolve form of this same gap. - [Factories](https://modern-di.modern-python.org/providers/factories/#creator) — how parameters are parsed and wired. # Circular Dependency Error This error occurs when providers form a dependency cycle, meaning A depends on B which depends back on A (directly or through intermediate providers). ## Understanding the Error When you see this error: ```text Container.validate() found 1 issue(s): CircularDependencyError CircularDependencyError (1): - Circular dependency detected: ServiceA └─> ServiceB └─> ServiceA Check your provider graph for unintended cycles. ``` It means the listed providers form a cycle that cannot be resolved. Each hop in the arrow chain may also end with a pointer to where that provider was declared (module and line number), making it easier to locate the offending provider in a large codebase. ## How to Detect ### The runtime cycle guard (without `validate()`) Resolving from an unvalidated cyclic graph still raises `CircularDependencyError`: the first resolve overflows the stack, and `Container.resolve_provider` catches that `RecursionError`, re-walks the static graph from the failing provider, and — since a cycle is reachable — raises `CircularDependencyError` (with the same cycle-path rendering shown above) `from` the original `RecursionError`. A creator that merely recurses on its own, with no actual cycle in the provider graph, still raises the original `RecursionError` unchanged — only a real static cycle gets converted. This guard runs on every resolve, whether or not `validate()` was ever called. ### Cycle detection with `validate()` Calling `validate()` up front finds the *same* cycle earlier, and finds *every* issue in the graph in one pass (not just the one a particular resolve happens to hit) — prefer it in development: ```python from modern_di import Container container = Container(groups=[MyGroup]) container.validate() # raises ValidationFailedError (wraps CircularDependencyError) if a cycle exists ``` ## How to Resolve 1. **Break the cycle with an interface/protocol** - introduce an abstraction that one side depends on instead of the concrete type 1. **Use `kwargs` to inject one dependency manually** - pass a factory or value via `kwargs` instead of relying on automatic resolution 1. **Restructure your dependencies** - extract shared logic into a third provider that both can depend on without forming a cycle ## See also - [Errors and exceptions](https://modern-di.modern-python.org/providers/errors-and-exceptions/index.md) - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — the validation section. # CreatorCallError **Symptom** Raised naming the creator that could not be called and the underlying `TypeError`, with a pointer to check `kwargs` and `skip_creator_parsing` usage. **Cause** Argument binding failed when calling the creator: the set of arguments `modern-di` assembled (static `kwargs` plus resolved dependencies) doesn't match the creator's signature — a required argument is missing, or an unexpected one was passed. This typically happens with `skip_creator_parsing=True` (where every required argument must be covered by `kwargs`) or a `kwargs` dict that drifted from the signature. This is a **wiring problem, not a bug inside your constructor** — an exception raised inside the creator's body (even a `TypeError`) propagates unchanged as itself, never wrapped in this error. **Fix** Make `kwargs` cover exactly what the signature requires. `.original_error` (also the `__cause__`) holds the binding `TypeError` naming the mismatched argument: ```python def create_service(host: str, port: int) -> Service: ... class Dependencies(Group): # Wrong: skip_creator_parsing=True but kwargs misses `port` service = providers.Factory( create_service, scope=Scope.APP, skip_creator_parsing=True, bound_type=Service, kwargs={"host": "localhost"}, ) # Right service = providers.Factory( create_service, scope=Scope.APP, skip_creator_parsing=True, bound_type=Service, kwargs={"host": "localhost", "port": 5432}, ) ``` Without `skip_creator_parsing`, unknown `kwargs` keys are caught earlier, at declaration time — see the page below. ## See also - [Unknown factory kwarg](https://modern-di.modern-python.org/troubleshooting/unknown-factory-kwarg-error/index.md) — the declaration-time form of a kwargs mismatch. - [Factories: skip_creator_parsing](https://modern-di.modern-python.org/providers/factories/#skip_creator_parsing). # ContextProvider has no value A `ContextProvider(SomeType)` resolves by looking up `SomeType` in the container's context registry. If no value was registered, the outcome depends on how the provider is consumed: resolving it directly raises `ContextValueNotSetError`, while injecting it into a `Factory` parameter that has no value raises `ArgumentResolutionError` — **unless** that parameter has a default (the default is used; `None` is not injected) or is nullable `X | None` (then `None` is injected). ## Understanding the error ```text Cannot resolve dependency chain: REQUEST MyService caused by: Argument tenant of type cannot be resolved. Trying to build dependency . ``` The error is an `ArgumentResolutionError` rendered as a chain: the top frame shows which provider failed, and the `caused by` line names the specific parameter that could not be wired. The parameter cannot be resolved because the `ContextProvider` for `TenantId` has no value in this container's context registry — nothing was set for that type on this container. ## Common causes ### 1. `set_context` was called on the wrong container (scope mismatch) Context never propagates between containers — see [context propagation](https://modern-di.modern-python.org/providers/context/#context-propagation) for why. For a REQUEST-scoped provider, only the request container's registry is ever consulted — setting the value on the parent has no effect, regardless of build order. ```python # ❌ Broken: TenantId provider has scope=Scope.REQUEST, so it reads the REQUEST # container's registry. Setting it on the APP parent does nothing. app_container.set_context(TenantId, TenantId("acme")) # ignored for REQUEST-scoped providers request_container = app_container.build_child_container(scope=Scope.REQUEST) ``` Fix: set the value on the container whose scope matches the provider's scope: ```python # Option A: pass directly to the child when building it request_container = app_container.build_child_container( scope=Scope.REQUEST, context={TenantId: TenantId("acme")}, ) # Option B: set on the request container after building it request_container = app_container.build_child_container(scope=Scope.REQUEST) request_container.set_context(TenantId, TenantId("acme")) ``` ### 2. The `ContextProvider`'s scope doesn't match where you set the context `ContextProvider(TenantId, scope=Scope.APP)` looks up the value on the APP container. If you `set_context` on the REQUEST child container, the APP-scope provider doesn't see it. Fix: match the scope. If the value is per-request, declare `ContextProvider(TenantId, scope=Scope.REQUEST)` and `set_context` on the request container (or pass via `build_child_container(context=...)`). ### 3. Framework integration didn't inject the expected request Framework integrations (`modern-di-fastapi`, `modern-di-litestar`) register the per-request `Request`/`WebSocket` automatically. If your code expects, say, `fastapi.Request` but you're outside the framework's request lifecycle (a background task, a CLI command), no `Request` is in context and the lookup fails. Fix: only depend on framework-injected context inside the framework's request handling. For background tasks, build the REQUEST child container yourself and pass the necessary context. ## See also - [Context Provider](https://modern-di.modern-python.org/providers/context/index.md) — the full `ContextProvider` and `set_context` API. - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — per-container context registries, why context never propagates between containers. - [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) — the canonical "construct in lifespan, inject as context" pattern. # Duplicate Type Error This error occurs when two or more providers are registered with the same `bound_type`. Modern-DI uses the `bound_type` to resolve dependencies by type, so each type must be unique in the providers registry. ## Understanding the Error When you see this error: ```text DuplicateProviderTypeError: Provider is duplicated by type . ``` The full runtime message also embeds the numbered resolution steps (set `bound_type=None` on one of the providers, or pass dependencies via `kwargs`) and a `See https://...` backlink to this page. It descends from `RegistrationError` → `ModernDIError` → `RuntimeError`, so `except DuplicateProviderTypeError`, `except RegistrationError`, and `except RuntimeError` all catch it. See [Errors and exceptions](https://modern-di.modern-python.org/providers/errors-and-exceptions/index.md). This typically happens when: 1. You have multiple factories that return the same type 1. You're using the same class in different contexts with different configurations ## How to Resolve To fix this error, you need to: 1. Set `bound_type=None` on one of the duplicate providers to make it unresolvable by type 1. Explicitly pass dependencies via the `kwargs` parameter to avoid automatic resolution Here's a complete example showing both steps: ```python from modern_di import Group, Scope, providers class DatabaseConfig: def __init__(self, connection_string: str) -> None: self.connection_string = connection_string class Repository: def __init__(self, db_config: DatabaseConfig) -> None: self.db_config = db_config class MyGroup(Group): # Step 1: Set bound_type=None on the secondary provider or for both providers # This provider can be resolved by type: container.resolve(DatabaseConfig) primary_db_config = providers.Factory( DatabaseConfig, scope=Scope.APP, kwargs={"connection_string": "postgresql://primary"} ) # This provider cannot be resolved by type # Must use: container.resolve_provider(MyGroup.secondary_db_config) secondary_db_config = providers.Factory( DatabaseConfig, scope=Scope.APP, bound_type=None, # <-- Step 1: Makes it unresolvable by type kwargs={"connection_string": "postgresql://secondary"} ) # Step 2: Explicitly pass dependencies via kwargs for second repository or for both primary_repository = providers.Factory( Repository, # <-- Implicit dependency, no kwargs scope=Scope.APP, ) secondary_repository = providers.Factory( Repository, scope=Scope.APP, kwargs={"db_config": secondary_db_config} # <-- Step 2: Explicit dependency ) ``` ## See also - [Factories](https://modern-di.modern-python.org/providers/factories/#bound_type) — the `bound_type` section. - [Errors and exceptions](https://modern-di.modern-python.org/providers/errors-and-exceptions/index.md) - [Missing provider](https://modern-di.modern-python.org/troubleshooting/missing-provider/index.md) For binding an abstract type to a concrete implementation, `Alias` is preferred over duplicate factories. # ChildContainerRegistrationError **Symptom** Raised from `Container.add_providers()`, naming the scope of the child container it was called on. **Cause** `add_providers()` was called on a child container rather than the root. The providers registry is shared tree-wide (every container in the chain points at the same registry), so registering from a child would silently mutate every container in the tree — this is disallowed rather than done implicitly. **Fix** Call `add_providers()` on the root container instead: ```python app_container = Container(scope=Scope.APP, groups=[MyGroup]) request_container = app_container.build_child_container(scope=Scope.REQUEST) # Wrong request_container.add_providers(late_provider) # raises ChildContainerRegistrationError # Right app_container.add_providers(late_provider) ``` If you only have a reference to the child container at the call site, keep a reference to the root container around (e.g. store it at app startup) instead of walking up via `parent_container`. ## See also - [Container: registering providers after construction](https://modern-di.modern-python.org/providers/container/#registering-providers-after-construction). # GroupScopeConflictError **Symptom** Defining a `Group` subclass raises at class-creation (import) time. The error names a provider and the two groups that disagree about its scope. **Cause** A module-level provider instance was created without an explicit `scope=`, so it takes its scope from whichever `class ...(Group, scope=...)` body stamps it first. When that same instance is also referenced from a second group whose default scope differs, the two stamps conflict — the provider cannot have two different scopes, and import order must never be what silently decides which one wins. **Fix** Three ways to resolve it, pick whichever fits: ```python # 1. Set scope= explicitly on the shared provider — explicit always wins over a group default. shared = providers.Factory(SomeService, scope=Scope.REQUEST) # 2. Align the two groups' default scopes so they agree. class GroupA(Group, scope=Scope.REQUEST): svc = shared class GroupB(Group, scope=Scope.REQUEST): svc = shared # 3. Give each group its own provider instance instead of sharing one. class GroupA(Group, scope=Scope.REQUEST): svc = providers.Factory(SomeService) class GroupB(Group, scope=Scope.ACTION): svc = providers.Factory(SomeService) ``` Inspect `.provider_name`, `.first_group`/`.first_scope`, and `.second_group`/`.second_scope` on the exception to see exactly which provider and groups collided. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the scope hierarchy and how a provider's scope is chosen. # ProviderScopeFrozenError **Symptom** Defining a `Group` subclass raises at class-creation (import) time. The error names a provider, the group that tried to change its scope, and the two scopes involved — and says the provider is already registered with a container. **Cause** A provider created without an explicit `scope=` takes its scope from whichever `class ...(Group, scope=...)` body stamps it first. A group declared **without** a `scope=` kwarg stamps nothing, so a provider listed only in such a group keeps the `Scope.APP` default and stays unclaimed — a later group is still free to stamp it. That is fine until the provider has been registered with a container. Registration compiles a resolver for the provider, and that resolver **captures the scope as it was at compile time**. Changing the scope afterwards would apply only to resolvers compiled later, so the same provider would resolve one way through the existing container and another way through a fresh one. Rather than let the two disagree silently, the scope is frozen at registration and the change is rejected. ```python shared = providers.Factory(SomeService) # no explicit scope -> APP default, unclaimed class PlainGroup(Group): # no scope= -> stamps nothing svc = shared container = Container(scope=Scope.APP, groups=[PlainGroup]) # registers + compiles class ScopedGroup(Group, scope=Scope.REQUEST): # ProviderScopeFrozenError svc = shared ``` **Fix** ```python # 1. Set scope= explicitly on the provider — explicit always wins over a group default, # and the provider is never left unclaimed in the first place. shared = providers.Factory(SomeService, scope=Scope.REQUEST) # 2. Declare every group that lists the provider before building the container. class PlainGroup(Group): svc = shared class ScopedGroup(Group, scope=Scope.REQUEST): svc = shared container = Container(scope=Scope.APP, groups=[ScopedGroup]) # now consistent # 3. Give the scoped group its own provider instance instead of sharing one. class ScopedGroup(Group, scope=Scope.REQUEST): svc = providers.Factory(SomeService) ``` Inspect `.provider_name`, `.group_name`, `.current_scope`, and `.new_scope` on the exception to see exactly which provider and group collided. Note the difference from [`GroupScopeConflictError`](https://modern-di.modern-python.org/troubleshooting/group-scope-conflict-error/index.md): that one fires when two groups *both* declare a scope and disagree, whether or not anything is registered. This one fires when a single group would change the scope of a provider that a container has already compiled. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/index.md) — the scope hierarchy and how a provider's scope is chosen. - [GroupScopeConflictError](https://modern-di.modern-python.org/troubleshooting/group-scope-conflict-error/index.md) — two groups disagreeing about a scope. # UnknownFactoryKwargError **Symptom** Raised at `Factory(...)` declaration time, listing the `kwargs` key(s) that don't match the creator's signature, the known parameter names, and a "did you mean" suggestion when a close match exists. **Cause** A key in `kwargs={...}` doesn't correspond to any parameter of the creator — usually a typo, or a key left over after the creator's signature was renamed/refactored. The creator has no `**kwargs` catch-all, so `modern-di` can validate the keys eagerly at declaration time rather than only failing at call time. **Fix** Match the `kwargs` keys to the creator's actual parameter names: ```python def create_service(connection_string: str) -> Service: ... class Dependencies(Group): # Wrong: typo — raises UnknownFactoryKwargError, suggests "connection_string" service = providers.Factory( create_service, scope=Scope.APP, kwargs={"conection_string": "..."} ) # Right service = providers.Factory( create_service, scope=Scope.APP, kwargs={"connection_string": "..."} ) ``` If the creator genuinely accepts arbitrary keyword arguments (`**kwargs` in its signature), this check is skipped automatically — no escape hatch needed. ## See also - [Factories: kwargs](https://modern-di.modern-python.org/providers/factories/#kwargs). # UnsupportedCreatorParameterError **Symptom** Raised at `Factory(...)` declaration time, naming the creator, the parameter, and the reason it can't be wired automatically. **Cause** The creator has a parameter shape `modern-di` cannot resolve by type: a positional-only parameter with no default (`def f(x, /)`), or a parameterized generic annotation (`list[X]`, `dict[str, Y]`, etc.) with no default and no matching `kwargs` entry. Both are declaration-time checks, not resolve-time ones. **Fix** Pick one of three escape routes, in order of preference: ```python def create_thing(items: list[Item], /) -> Thing: ... class Dependencies(Group): # 1. Give the parameter a default # def create_thing(items: list[Item] = ()) -> Thing: ... # 2. Supply the value via kwargs at declaration time thing = providers.Factory(create_thing, scope=Scope.APP, kwargs={"items": []}) # 3. Skip creator parsing entirely and supply every argument via kwargs thing2 = providers.Factory( create_thing, scope=Scope.APP, skip_creator_parsing=True, kwargs={"items": []} ) ``` **Escape hatches** `skip_creator_parsing=True` bypasses signature parsing altogether (option 3 above) — use it when a creator has several unsupported parameter shapes rather than fixing each one individually. ## See also - [Factories: creator-signature support matrix](https://modern-di.modern-python.org/providers/factories/#creator-signature-support-matrix). # Scope chain violation This error fires when a provider depends on another provider at a deeper (shorter-lived) scope — see [the scope dependency rule](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) for why that's disallowed. ## Understanding the error You'll see something like: ```text Container.validate() found 1 issue(s): InvalidScopeDependencyError InvalidScopeDependencyError (1): - Provider UserCache (scope APP) declares parameter 'session' typed as a provider of Session at deeper scope REQUEST. A provider cannot depend on a deeper-scoped provider. ``` The fix is always to make the depender's scope equal to or shorter than the dependee's. In the example above, `UserCache` should be REQUEST-scoped, not APP-scoped. This particular message is a single static check with no chain attached; if the same violation instead surfaces at runtime as `ScopeNotInitializedError` or `ScopeSkippedError`, their breadcrumb lines may end with a pointer to where the offending provider was declared (module and line number). ## Common cases 1. **Forgot `scope=Scope.REQUEST` on a repository.** Defaults to `Scope.APP` if omitted. A repository that holds a session needs `scope=Scope.REQUEST`. 1. **Helper or utility provider auto-defaulted to APP.** Same as above — anything that consumes the session is REQUEST-scoped. 1. **Choice factory consuming the request.** A factory that depends on the framework's `Request` is REQUEST-scoped; you cannot resolve it from the APP container. ## How to fix Bump the depender's scope: ```python class Dependencies(Group): session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) # ❌ APP-scoped — fails validation user_repository = providers.Factory(UserRepository) # ✅ REQUEST-scoped — matches session's lifetime user_repository = providers.Factory( UserRepository, scope=Scope.REQUEST, ) ``` ## Detect early `container.validate()` runs this check at startup, before the first request. Call it — the diagnostic is much clearer than the runtime symptoms. ## See also - [Scopes](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) — the lifetime model and the "max of dependencies' scopes" rule. - [Lifecycle](https://modern-di.modern-python.org/providers/lifecycle/index.md) — `container.validate()` and other startup checks. # FinalizerError **Symptom** Raised by `close_sync()` / `close_async()`, embedding the list of finalizer exceptions that occurred during cleanup and whether the close was sync or async. **Cause** One or more cached providers' finalizers raised while the container was closing. Closing never stops at the first failure — every finalizer runs regardless — so this error aggregates all of them rather than surfacing just one. **Fix** Inspect `.finalizer_errors` for the individual exceptions and fix the offending finalizer(s): ```python try: container.close_sync() except exceptions.FinalizerError as exc: for err in exc.finalizer_errors: print(type(err).__name__, err) ``` Because every finalizer still ran, a broken one doesn't leak a resource a later finalizer would have closed — only the exceptions themselves need attention, not the cleanup order. `.is_async` tells you whether `close_sync()` or `close_async()` produced the error. **Escape hatches** If one entry in `.finalizer_errors` is an `AsyncFinalizerInSyncCloseError`, that specific resource's cache was retained (not lost) — calling `await container.close_async()` afterward finalizes it and completes cleanup. ## See also - [Lifecycle: close-failure semantics](https://modern-di.modern-python.org/providers/lifecycle/#close-failure-semantics). # AsyncFinalizerInSyncCloseError **Symptom** Arrives wrapped inside a `FinalizerError` (as one entry in `.finalizer_errors`), naming the type whose cached instance has an async finalizer. **Cause** `close_sync()` cannot `await` anything. When it reaches a cached resource whose `CacheSettings` finalizer is an async function, it can't run it synchronously, so it records this error for that entry instead — and, unlike a normal finalizer failure, keeps the resource's cache entry intact rather than discarding it. **Fix** Use `close_async()` (or `async with container:`) for containers that hold any resource with an async finalizer — it's the only path that can actually run that cleanup: ```python container.resolve(AsyncResource) # has an async finalizer try: container.close_sync() except exceptions.FinalizerError as exc: # exc.finalizer_errors contains an AsyncFinalizerInSyncCloseError — cache retained, not lost ... await container.close_async() # recovers: runs the async finalizer, completes cleanup ``` Prefer `async with container:` (or `await close_async()`) by default whenever any provider might have an async finalizer, and treat `close_sync()` as a fallback only for containers you know are entirely sync. ## See also - [Lifecycle: close-failure semantics](https://modern-di.modern-python.org/providers/lifecycle/#close-failure-semantics). # GroupInstantiationError **Symptom** Raised naming the `Group` subclass someone tried to instantiate, saying it cannot be created as an object. **Cause** A `Group` subclass was called like a constructor (`MyGroup()`). Groups are namespaces for declaring providers as class attributes — they're never meant to be instantiated, only passed by class reference to `Container(groups=[MyGroup])` or read via `MyGroup.some_provider`. **Fix** Use the class itself, not an instance: ```python class Dependencies(Group): service = providers.Factory(Service, scope=Scope.APP) # Wrong deps = Dependencies() # raises GroupInstantiationError # Right container = Container(groups=[Dependencies]) service = container.resolve_provider(Dependencies.service) ``` This usually happens from a habit carried over from frameworks where a container/module *is* instantiated, or from accidentally writing `Dependencies()` instead of `Dependencies` in a type annotation or default value. ## See also - [Multi-Group organization](https://modern-di.modern-python.org/recipes/multi-group/index.md) — organizing providers across several `Group` classes. # Migration # Migration Guide: Upgrading to modern-di 1.x Historical guide This guide covers migrating from 0.x to 1.x. The APIs shown here (`AsyncContainer`, `SyncContainer`, `providers.Singleton`, `.cast`) were **removed in 2.x**. If you are on 1.x today, also follow the [2.x migration guide](https://modern-di.modern-python.org/migration/to-2.x/index.md) to reach the current API. modern-di 1.x inverts where resolution methods live and replaces a handful of provider types. Breaking changes, once: 1. **`BaseGraph` → `Group`**; single `Container` → `AsyncContainer` or `SyncContainer` (async supports both sync and async resolution; sync is sync-only). ```python # Before (0.x) from modern_di import BaseGraph, Container # After (1.x) from modern_di import Group, AsyncContainer, SyncContainer sync_container = SyncContainer(groups=ALL_GROUPS) sync_container.enter() # replaces Container().sync_enter() ``` 1. **Resolution moved from provider to container**, and can now target a type directly (requires passing `groups=` at construction): ```python # Before (0.x) instance = provider.sync_resolve(container) instance = await provider.async_resolve(container) # After (1.x) instance = container.sync_resolve_provider(provider) instance = await container.resolve_provider(provider) instance = container.sync_resolve(SomeType) # new: resolve by type instance = await container.resolve(SomeType) ``` Manual provider overrides and the way dependencies are declared in web-framework applications changed accordingly — both now go through the container and integration APIs. 1. **`Selector` and `ContextAdapter` removed** — replace both with `Factory` + `ContextProvider`: ```python # Before (0.x) dynamic_engine = providers.Selector(Scope.REQUEST, fetch_db_mode, write=w, read=r) # After (1.x) mode = providers.ContextProvider(Scope.REQUEST, SomeContextType) dynamic_engine = providers.Factory(Scope.REQUEST, choose_engine, context=mode.cast, write=w.cast, read=r.cast) ``` 1. **`AttrGetter` removed** — reference the provider directly, or write a small factory function that extracts the attribute. 1. **Factory attribute access removed** (`.async_provider`/`.sync_provider`) — inject the container itself and resolve dependencies manually instead of injecting a factory function. 1. **`async_enter()` → `enter()`.** ## More See the [2.x migration guide](https://modern-di.modern-python.org/migration/to-2.x/index.md) to move from here to the current API. # Migration Guide: Upgrading to modern-di 2.x modern-di 2.x merges the container classes, moves providers to keyword-only arguments, removes four provider types in favor of `Factory`, and drops async resolution. Breaking changes, once: 1. **`AsyncContainer`/`SyncContainer` → `Container`** (single class, both sync and async operations): ```python # Before (1.x) from modern_di import AsyncContainer, SyncContainer async_container = AsyncContainer(groups=ALL_GROUPS) async_container.enter() # After (2.x) from modern_di import Container container = Container(groups=ALL_GROUPS, validate=True) # no explicit enter() needed container.close_sync() # or: await container.close_async() ``` `with`/`async with container.build_child_container(...)` still works for automatic cleanup; `close_sync()`/`close_async()` are also available for manual lifecycle control. The framework integration packages were updated with matching new APIs. 1. **Provider constructor arguments became keyword-only.** ```python # Before (1.x) factory = providers.Factory(Scope.REQUEST, MyClass, arg1="value1") # After (2.x) factory = providers.Factory(MyClass, scope=Scope.REQUEST, kwargs={"arg1": "value1"}) ``` Since 2.27, the subject argument (`creator` / `context_type` / `source_type`) is accepted positionally again; all other parameters remain keyword-only. 1. **`Singleton`, `Resource`, `Dict`, `List` removed** — all four map onto `Factory`: ```python # Before (1.x) singleton = providers.Singleton(Scope.APP, create_singleton) resource = providers.Resource(Scope.REQUEST, create_resource) # After (2.x) singleton = providers.Factory(create_singleton, scope=Scope.APP, cache=True) resource = providers.Factory( create_resource, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=lambda r: r.close()), ) ``` `Dict`/`List` have no provider equivalent — write a plain creator function that returns the collection and wrap it in a `Factory`. `clear_cache` defaults to `True` (old `Resource` semantics: finalizer runs on close, instance rebuilt on next resolve); set `clear_cache=False` only when the same object must survive a close→reopen cycle. 1. **Resolution is sync-only** — no more `sync_` prefix, no `await` on resolution (async *finalizers* are still supported via `CacheSettings(finalizer=async_fn)` and `await container.close_async()`): ```python # Before (1.x) instance = await container.resolve_provider(provider) # After (2.x) instance = container.resolve_provider(provider) ``` 1. **`.cast` removed** — wiring is by type instead: | 1.x | 2.x | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `dep=other_provider.cast` (a provider dependency) | Drop the argument — annotate the creator parameter with the dependency's type. | | `value=settings.host` (a static value) | Pass it in `kwargs={"value": ...}`. | | a request/context value | Register a `ContextProvider` for that type (see [Context](https://modern-di.modern-python.org/providers/context/index.md)). | ```python # 1.x service = providers.Factory(MyService, db_engine=database_engine.cast) # 2.x — MyService.__init__(self, db_engine: DBEngine); resolved by type service = providers.Factory(MyService, scope=Scope.APP) ``` # Migration Guide: Upgrading to modern-di 3.x This document describes the changes required to migrate from modern-di 2.x to modern-di 3.0. ## Overview modern-di 3.0 flips five switches from warn-then-continue to raise/validate-by-default, and adds one more that has no 2.x precedent to warn from. Each of the five already has a 2.x signal — a warning that fires today wherever the 3.0 behavior would differ. If your 2.x test suite is green with the [readiness recipe](#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings) below escalating those five warnings to errors, **those five switches** are a no-op for you. 3.0 **additionally** requires a container to be opened (`with`/`async with`/`open()`) before it can `resolve` or `build_child_container` — switch 6 below — and changes `validate`'s constructor signature from `bool | None` to a plain `bool`. Neither has a 2.x warning to escalate: 2.x has no "unopened" state to signal on, and an explicit `validate=True` in 2.x validates eagerly at construction, a timing 3.0 changes without ever warning about it. These are genuine hard breaks — a green suite under the recipe does not, by itself, get you past them. See [switch 4](#4-validate-runs-at-container-entry-on-by-default) and [switch 6](#6-a-container-must-be-opened-before-use) below. ## The six switches | 3.0 change | 2.x signal | | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Reusing a closed container raises `ContainerClosedError` | `ContainerClosedWarning` | | `Alias(scope=)` parameter removed | `DeprecationWarning` | | `Factory(cache_settings=)` removed | `DeprecationWarning` | | `validate` defaults to `True` and runs at container entry (`open()`/`with`) | `UnvalidatedContainerWarning` — covers the *unset* case only; see below | | Direct resolve of an unset `ContextProvider` raises `ContextValueNotSetError` | `ContextValueNoneWarning` | | A container must be opened before `resolve`/`build_child_container` | **none** — inherent hard break, no 2.x state to warn from | ## Key Changes ### 1. Closed containers raise instead of self-healing In 2.x, resolving from (or building a child of) a closed container emits `ContainerClosedWarning` and transparently reopens the container so the call still succeeds. In 3.0 the same call raises `ContainerClosedError` instead. **Before (2.x):** ```python container = Container(scope=Scope.APP, groups=[MyGroup], validate=True) container.close_sync() # ContainerClosedWarning: Container (scope APP) is closed; resolving from it or # building a child is deprecated and will raise ContainerClosedError in modern-di # 3.0. Re-enter the container with `with`/`async with`, or call `open()`, before # reusing it. service = container.resolve(MyService) # succeeds — container self-reopens ``` **After (3.0):** ```python container = Container(scope=Scope.APP, groups=[MyGroup], validate=True) with container: service = container.resolve(MyService) # works — container is open inside the block # the `with` block closed the container on exit service = container.resolve(MyService) # raises ContainerClosedError — reused after close ``` Re-enter the container with `with`/`async with`, or call `container.open()`, before reusing it. This is one half of a single rule: **a container must be open to be used.** This switch is the *closed-after-use* half (a container that was open, then closed); [switch 6](#6-a-container-must-be-opened-before-use) below is the *never-opened* half (a fresh container that was never entered at all). Both raise the same `ContainerClosedError`, and both are fixed the same way — enter the container with `with`/`async with`, or call `open()`, before resolving or building children. ### 2. `Alias(scope=)` parameter removed `Alias`'s effective scope has always been derived from its source provider; the `scope` argument never affected resolution. In 2.x, passing it emits a `DeprecationWarning`; in 3.0 the parameter is gone. **Before (2.x):** ```python from modern_di import Scope, providers # DeprecationWarning: The `scope` parameter of Alias is deprecated and ignored: # an alias's effective scope is derived from its source. It will be removed in # a future release. alias = providers.Alias(DatabaseProtocol, scope=Scope.APP) ``` **After (3.0):** ```python from modern_di import providers alias = providers.Alias(DatabaseProtocol) ``` ### 3. `Factory(cache_settings=)` removed `cache_settings=` was the pre-`cache=` spelling for tuning a `Factory`'s cache. In 2.x it still works but warns; in 3.0 only `cache=` is accepted. **Before (2.x):** ```python # DeprecationWarning: `cache_settings=` is deprecated; use `cache=` (pass # cache=True for defaults, or cache=CacheSettings(...) to tune). It will be # removed in a future release. factory = providers.Factory( create_resource, scope=Scope.REQUEST, cache_settings=providers.CacheSettings(finalizer=lambda resource: resource.close()), ) ``` **After (3.0):** ```python factory = providers.Factory( create_resource, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=lambda resource: resource.close()), ) ``` ### 4. `validate` runs at container entry, on by default The final 3.0 form differs from what 2.x signals in two ways, so read this one carefully. **The signature.** In 2.x, `Container`'s `validate` argument is `bool | None = None`: unset (`None`) skips validation but emits `UnvalidatedContainerWarning`; `False` skips it silently; `True` enables it. In 3.0, the parameter is a plain `validate: bool = True` — the `None` sentinel is gone. Passing `validate=False` still means "off"; there is no other spelling to adopt for the unset case, because unset now *is* the default-on case. **The timing.** In 2.x, `validate=True` validates **eagerly at construction** — `Container(...)` itself raises `ValidationFailedError` if the graph is broken. In 3.0, validation never runs in `__init__`. It runs once, at container **entry** — `open()`, or `with`/`async with` (which call `open()`) — so an invalid graph raises there instead. This lets a framework integration register its own providers (e.g. via `add_providers`) after construction and still have the complete graph validated before first use. `validate=True` is **not eager**: if you need a construction-time check, call `container.validate()` explicitly right after building it. This timing change has no 2.x warning: an explicit `validate=True` caller in 2.x sees no deprecation notice, because from 2.x's perspective that call already validates and already succeeds — 2.x has nothing to warn about a timing it doesn't yet have. `UnvalidatedContainerWarning` only ever covered the *unset* case (2.x's "no explicit `validate=` argument" state); it says nothing about when validation happens once enabled. Escalating it to an error still gets you a 2.x-clean signal for switching the *default* to on — it does not, and cannot, warn you about the *timing* move for callers who already pass `validate=True`. **Before (2.x):** ```python # UnvalidatedContainerWarning: This root container was created without an # explicit `validate` argument. modern-di 3.0 runs validate() at container # entry by default. Pass validate=True to adopt the 3.0 behavior now, or # validate=False to keep validation off. container = Container(scope=Scope.APP, groups=[MyGroup]) container.resolve(MyService) # Explicit opt-in — validates immediately, no warning, at construction: container = Container(scope=Scope.APP, groups=[MyGroup], validate=True) # raises here if broken ``` **After (3.0):** ```python # validate is on by default; it runs once at open(), not at construction. with Container(scope=Scope.APP, groups=[MyGroup]) as container: # validate() already ran here — raises ValidationFailedError before this # block is entered if the graph has cycles or scope-ordering problems. service = container.resolve(MyService) # Opt out entirely — this spelling works identically before and after 3.0. container = Container(scope=Scope.APP, groups=[MyGroup], validate=False) # Want the check at construction time instead of at open()? Call it yourself. container = Container(scope=Scope.APP, groups=[MyGroup]) container.validate() # raises ValidationFailedError here if the graph is broken ``` Child containers (built via `build_child_container`) never validate, in either version — this switch only affects root containers. **Changed again in 3.1** — validation is explicit-only; `open()` no longer runs it either. See the [3.1 note under switch 6](#6-a-container-must-be-opened-before-use) below for the full correction. ### 5. Direct resolve of an unset `ContextProvider` raises In 2.x, resolving a type backed by a `ContextProvider` with no value set emits `ContextValueNoneWarning` and returns `None`. In 3.0 the same call raises `ContextValueNotSetError`. This only affects a *direct* resolve of the context type; a `Factory` parameter backed by the same `ContextProvider` continues to follow its own default/nullable/required disposition, unchanged. **Before (2.x):** ```python # ContextValueNoneWarning: No context value is set for (scope # APP); returning None. modern-di 3.0 raises ContextValueNotSetError here. # Pass context={...} to the container or call set_context(). value = container.resolve(SomeContextType) # None ``` **After (3.0):** ```python value = container.resolve(SomeContextType) # raises ContextValueNotSetError ``` Pass `context={SomeContextType: value}` to the container (or its ancestor at the `ContextProvider`'s scope), or call `container.set_context(SomeContextType, value)`, before resolving. ### 6. A container must be opened before use New in 3.0, added mid-development, with **no 2.x deprecation signal at all** — 2.x has no "unopened" state, so there was never anything for it to warn about. A freshly constructed container now starts unopened; using it before entering it — `resolve`, `resolve_provider`, `build_child_container` — raises `ContainerClosedError`. Enter it with `with`/`async with`, or call `open()` directly (for a callback-style lifecycle that cannot use a `with` block), before the first use. Child containers (from `build_child_container`) also start unopened and must be entered themselves before they can be used. This is the *never-opened* half of the same rule as [switch 1](#1-closed-containers-raise-instead-of-self-healing) above (the *closed-after-use* half): **a container must be open to be used**, whether it was never opened or was opened and then closed. Both cases raise the identical `ContainerClosedError`, with a message that names which state applies, and both are fixed the same way. **Before (2.x):** ```python container = Container(scope=Scope.APP, groups=[MyGroup]) service = container.resolve(MyService) # works — no open() call needed child = container.build_child_container(scope=Scope.REQUEST) value = child.resolve(SomeContextType) # works — no open() call needed either ``` **After (3.0):** ```python container = Container(scope=Scope.APP, groups=[MyGroup]) service = container.resolve(MyService) # raises ContainerClosedError: not open # Fix: enter the container first. with Container(scope=Scope.APP, groups=[MyGroup]) as container: service = container.resolve(MyService) # works # A child also starts unopened and must be entered before use. with container.build_child_container(scope=Scope.REQUEST) as child: value = child.resolve(SomeContextType) # works # Or, without a `with` block: container = Container(scope=Scope.APP, groups=[MyGroup]) container.open() service = container.resolve(MyService) # works ``` Because there is no 2.x signal for this one, the [readiness recipe](#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings) below cannot surface it in advance — a green 2.x suite under that recipe still needs every construct-then-use call site audited for a matching `with`/`open()` before it can run against 3.0. **Changed again in 3.1.** This requirement is relaxed, not reversed: see the [3.1 release notes](https://github.com/modern-python/modern-di/releases) for the full change. A container is **open from construction** again — `closed = False` the moment `Container(...)` returns, no `open()` step required — and reusing a container after an explicit close warns (`ContainerClosedWarning`) and reopens instead of raising `ContainerClosedError`. An earlier version of this note said every pattern shown above under "After (3.0)" kept working unchanged, including that `with`/`open()` "still validates, still fails fast." That part was wrong and has been corrected here: **validation is explicit-only as of 3.1.** `open()` (and `with`/`async with`, which call it) no longer runs `validate()` — it only clears `closed`, unconditionally. Nothing validates automatically: not construction, not `open()`, not `add_providers`, not `resolve()`. A test suite that asserts on `closed` will fail 3.1 is a relaxation for *callers*, but not for *tests that assert the lifecycle flag*. Two 3.0-era assertions break, and both were found in the wild across the official integrations: - `assert container.closed is True` on a freshly built container — it is `False` in 3.1, because construction leaves it open. - `with pytest.raises(ValidationFailedError): container.open()` — `open()` validates nothing in 3.1, so it does not raise. Both are mechanical to fix, but the second needs care: if a test's *subject* is the lifecycle transition ("this signal opens the root"), flipping the assertion makes it pass without proving anything. Close the container first, so the transition stays observable. If the subject is the validation-ordering rule, point it at `container.validate()` — the rule still holds, it just binds a different call. `container.validate()` is the only thing that walks the graph, and `Container(validate=...)` is deprecated — passing `True` or `False` is ignored and emits `ValidateArgumentWarning` (a `DeprecationWarning`), removed in 4.0. So in the "After (3.0)" example above, the comment `# validate() already ran here` no longer holds in 3.1 — call `container.validate()` explicitly, right after construction (or after an integration's `setup_di` registers its own providers via `add_providers`, if you want the complete graph checked), for the same fail-fast check. `with`/`open()` still open the container and still guarantee `close_*` runs finalizers on the way out — that part of "After (3.0)" is unaffected — this switch (mandatory open) just stops being mandatory for code that skips it, and validation timing is fully decoupled from it. ## Readiness recipe: escalating warnings to errors with `filterwarnings` This is the one place in the docs that lists the full `filterwarnings` escalation recipe; every other page that mentions escalating a specific warning links back here. This recipe covers switches 1, 2, 3, and 5 fully, and switch 4 only for the *unset-`validate`* case — the case `UnvalidatedContainerWarning` actually warns about. It has **nothing** to say about switch 6 (mandatory-open) or about switch 4's timing move for callers who already pass `validate=True` explicitly: both are hard breaks with no 2.x warning to escalate. A green suite under this recipe rules out five-and-a-half of the six switches; you still need to audit construct-then-use call sites for `with`/`open()` (switch 6) and, if you pass `validate=True` explicitly today, re-check any code that depends on validation happening at construction rather than at `open()` (switch 4). `ContainerClosedWarning` was a `DeprecationWarning` in 2.x. As of 3.1 it is a `RuntimeWarning` instead — deliberately, since CPython hides `DeprecationWarning` outside `__main__`, which would hide exactly the diagnostic this warning exists for — so the blanket categories below no longer catch it; add its dedicated-class filter alongside them. `ContextValueNoneWarning` subclasses `DeprecationWarning`; `UnvalidatedContainerWarning` subclasses `FutureWarning`; the `Alias(scope=)` and `Factory(cache_settings=)` warnings are plain `DeprecationWarning` (they have no dedicated subclass). Escalating both categories to errors, plus `ContainerClosedWarning`'s own class, therefore turns all five signals into failures a green test suite would catch: ```python import warnings from modern_di import exceptions warnings.filterwarnings("error", category=DeprecationWarning) warnings.filterwarnings("error", category=FutureWarning) warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning) ``` plus the pytest variant: ```toml [tool.pytest.ini_options] filterwarnings = [ "error::DeprecationWarning", "error::FutureWarning", "error::modern_di.exceptions.ContainerClosedWarning", ] ``` Don't add a `module=` filter here It's tempting to scope the filter to modern-di with `module=r"modern_di(\..*)?"`, but that argument matches the module of the *warned-from* frame at the warning's `stacklevel`, not the module that owns the warning class. Three of the five signals (`UnvalidatedContainerWarning`, and the `Alias(scope=)` / `Factory(cache_settings=)` warnings) are raised directly inside the constructor call with `stacklevel=2`, which attributes them to *your* calling module — not `modern_di` — so a `module=r"modern_di(\..*)?"` filter silently fails to escalate them. The other two (`ContainerClosedWarning`, `ContextValueNoneWarning`) fire deep inside a resolve call, where the `stacklevel=2` frame happens to still be inside `modern_di`, so they *would* match — the inconsistency is exactly why `module=` isn't part of the recipe above. **Changed again in 3.1.** `ContainerClosedWarning` now computes its `stacklevel` (via `_caller_stacklevel`) so it attributes *outside* `modern_di`, and `ContextValueNoneWarning` has no raise sites left at all — so on 3.1 a `module=r"modern_di(\..*)?"` filter escalates none of the five. The paragraph above describes 2.x, which is what this page's recipe runs against. If the broad category filter is too wide for your process (e.g. another dependency's `DeprecationWarning`s should stay warnings), escalate the three dedicated subclasses individually instead — this covers switches 1, 4, and 5 precisely, but not 2 and 3, since those two have no dedicated class in 2.x: ```python from modern_di import exceptions warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning) warnings.filterwarnings("error", category=exceptions.UnvalidatedContainerWarning) warnings.filterwarnings("error", category=exceptions.ContextValueNoneWarning) ``` ## Deprecation policy Every breaking change that *can* be signalled in modern-di is warned for at least one minor release cycle before it flips or is removed at the next major. If you're on a 2.x release and see none of the five warnings above under the readiness recipe, those five switches require no code changes on your part. That policy has a boundary: it only covers changes 2.x has a state to warn from. Mandatory-open (switch 6) is a new requirement with no 2.x precedent — a 2.x container has no "unopened" state, so there was never a warning to add. Likewise, switch 4's timing move (construction to `open()`) only affects callers who already pass `validate=True`, a code path 2.x treats as already-correct and so never warns about. Neither omission is an oversight in this guide; there is no signal to point to. Upgrading to 3.0 requires opening every container you construct-then-use, in addition to a clean run under the readiness recipe above. # Migration from `that-depends` This guide walks an existing `that-depends` codebase through the move to `modern-di`. Every provider type and core concept in `that-depends` has either a documented mapping below or an explicit note that there is no direct equivalent (with a workaround). ## 1. Install Core package: ```bash uv add modern-di ``` ```bash pip install modern-di ``` ```bash poetry add modern-di ``` Framework integrations and the pytest helper live in separate packages — install only what you need: ```bash uv add modern-di-fastapi # FastAPI uv add modern-di-litestar # Litestar uv add modern-di-faststream # FastStream uv add modern-di-typer # Typer uv add modern-di-pytest # pytest fixtures ``` ```bash pip install modern-di-fastapi pip install modern-di-litestar pip install modern-di-faststream pip install modern-di-typer pip install modern-di-pytest ``` ## 2. Key conceptual shifts Three things change in how you think about the framework. Most migration confusion comes from these: - **`Group` is a schema, `Container` is the runtime.** In `that-depends`, a `BaseContainer` subclass is *both* the schema and the runtime — you resolve directly from the class. In `modern-di`, `Group` is a namespace-only class (you cannot instantiate it) and you create the runtime `Container(groups=[MyGroup])` separately, typically once at app start. All resolution, overrides, and lifecycle calls go through that `Container` instance. - **Resolution is sync-only.** `modern-di` does not have `AsyncFactory`, `AsyncSingleton`, or `await container.resolve(...)`. Async work happens in the framework's lifespan — see [§6](#6-async-resources). There is no plan to add async resolution back. - **Scopes are explicit.** `Scope.APP → SESSION → REQUEST → ACTION → STEP`, with [the scope dependency rule](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) enforced at validation time. Framework integrations create the per-request child container automatically. ## 3. Provider mapping Use this table as the index for the rest of the guide. | `that-depends` | `modern-di` replacement | Where to look | | ------------------------------------------ | ---------------------------------------------------------------- | -------------------------------------------- | | `Factory` | `providers.Factory(...)` | [§4](#4-migrate-the-dependency-graph) | | `Singleton` | `providers.Factory(..., cache=True)` | [§4](#4-migrate-the-dependency-graph) | | `Resource` (sync gen / ctx mgr) | `providers.Factory(..., cache=CacheSettings(finalizer=...))` | [§4](#4-migrate-the-dependency-graph) | | `Resource` (async gen / ctx mgr) | Lifespan + `ContextProvider` (or sync creator + async finalizer) | [§6](#6-async-resources) | | `ContextResource` | `providers.Factory(..., scope=Scope.REQUEST)` | [§5](#5-context-resources-and-request-scope) | | `AsyncFactory` | Lifespan-managed; expose via `ContextProvider` | [§6](#6-async-resources) | | `AsyncSingleton` | Lifespan-managed; expose via `ContextProvider` | [§6](#6-async-resources) | | `Object` | `providers.Factory` with a creator that returns the value | [§4](#4-migrate-the-dependency-graph) | | `List` | `providers.Factory` with a creator that returns a list | [§4](#4-migrate-the-dependency-graph) | | `Dict` | `providers.Factory` with a creator that returns a dict | [§4](#4-migrate-the-dependency-graph) | | `Selector` | No direct equivalent — see [§9](#9-no-direct-equivalent) | | | `AttrGetter` (`provider.attr`) | No direct equivalent — see [§9](#9-no-direct-equivalent) | | | `ThreadLocalSingleton` | No direct equivalent — see [§9](#9-no-direct-equivalent) | | | `State` | `ContextProvider` + `set_context` | [§5](#5-context-resources-and-request-scope) | | `Provider.bind(Type)` | `providers.Alias(..., bound_type=...)` | [§4](#4-migrate-the-dependency-graph) | | `@inject` + `Provide[T]()` (web) | `FromDI(T)` from the framework integration | [§8](#8-framework-integration-and-routes) | | `@inject` + `Provide[T]()` (non-web) | Explicit `container.resolve(T)` | [§9](#9-no-direct-equivalent) | | `container_context()` | `container.build_child_container(scope=..., context=...)` | [§5](#5-context-resources-and-request-scope) | | `DIContextMiddleware` | `setup_di(app, container)` / `ModernDIPlugin(container)` | [§8](#8-framework-integration-and-routes) | | `fetch_context_item` / `_by_type` | `ContextProvider(T)` | [§5](#5-context-resources-and-request-scope) | | `init_resources()` | Lazy initialization — no equivalent needed | [§7](#7-lifecycle-and-testing) | | `tear_down()` / `tear_down_sync()` | `await container.close_async()` / `container.close_sync()` | [§7](#7-lifecycle-and-testing) | | `container.override_providers_sync({...})` | `container.override(provider, mock)` | [§7](#7-lifecycle-and-testing) | | `provider.override_sync(mock)` | `container.override(provider, mock)` | [§7](#7-lifecycle-and-testing) | ## 4. Migrate the dependency graph 1. Replace `BaseContainer` with `Group`. 1. Add an explicit `scope=` to each provider (defaults to `Scope.APP`). 1. Create the runtime container with `Container(groups=[MyGroup])`. In `modern-di`, the `Group` class is a schema only — you cannot resolve from it directly. When a provider is passed inside `kwargs={...}`, `modern-di` detects it and resolves it like any other dependency. There is no `.cast` indirection — drop those calls. ```python from that_depends import BaseContainer, providers from app import repositories from app.resources.db import create_sa_engine, create_session class Dependencies(BaseContainer): database_engine = providers.Resource(create_sa_engine, settings=settings.cast) session = providers.ContextResource(create_session, engine=database_engine.cast) decks_service = providers.Factory(repositories.DecksService, session=session) cards_service = providers.Factory(repositories.CardsService, session=session) ``` ```python from modern_di import Container, Group, Scope, providers from app import repositories from app.resources.db import close_sa_engine, close_session, create_sa_engine, create_session class Dependencies(Group): database_engine = providers.Factory( create_sa_engine, cache=providers.CacheSettings(finalizer=close_sa_engine), ) session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), kwargs={"engine": database_engine}, ) decks_service = providers.Factory( repositories.DecksService, scope=Scope.REQUEST, kwargs={"session": session}, ) cards_service = providers.Factory( repositories.CardsService, scope=Scope.REQUEST, kwargs={"session": session}, ) # Group is a schema. Create the runtime container once at app start. container = Container(groups=[Dependencies]) ``` ### Per-provider replacements **`Singleton`** → cached `Factory` of `APP` scope: ```python # that-depends some_singleton = providers.Singleton(SomeClass) # modern-di some_singleton = providers.Factory( SomeClass, cache=True, ) ``` **`Resource`** (sync generator or context manager) → cached `Factory` with a `finalizer`, splitting the generator into a creator and a finalizer function — see `database_engine` in the worked example above. **`Object`** → `Factory` whose creator returns the value. Define a small typed function (lambdas have no return annotation, which prevents resolution by type): ```python # that-depends api_key = providers.Object("secret-token") # modern-di class ApiKey(str): ... def _api_key() -> ApiKey: return ApiKey("secret-token") api_key = providers.Factory(_api_key, cache=True) ``` If you only need the value passed into one downstream provider, skip the wrapper and put it directly in that provider's `kwargs`. **`List` / `Dict`** → `Factory` with a creator that builds the collection: ```python # that-depends some_list = providers.List(provider1, provider2) # modern-di def build_list(a: SomeType1, b: SomeType2) -> list[object]: return [a, b] some_list = providers.Factory(build_list) ``` **`Provider.bind(Type)`** → `Alias`. Useful when you want an abstract type (`Protocol`, ABC) to resolve to a concrete registered provider: ```python # that-depends repo = providers.Factory(PostgresRepository).bind(Repository) # modern-di repo = providers.Factory(PostgresRepository, cache=True) abstract_repo = providers.Alias(PostgresRepository, bound_type=Repository) ``` ## 5. Context resources and request scope The `that-depends` `ContextResource` / `container_context()` / `State` / `fetch_context_item` family all collapse into two `modern-di` mechanisms: `Scope.REQUEST` (and below) providers, and `ContextProvider`. Declaring `scope=Scope.REQUEST` on a provider (e.g. `session` in the worked example above) is enough when a framework integration builds the per-request child container for you; `container_context()`'s manual case maps onto using that same child container as a context manager yourself. See [Building child containers](https://modern-di.modern-python.org/providers/scopes/#building-child-containers) for both forms. ### Injecting custom context (replaces `State`, `fetch_context_item`, `fetch_context_item_by_type`) Declare a `ContextProvider` for the type you want injected, then supply the instance when you build the child container — or via `set_context` before resolving: ```python from modern_di import Container, Group, Scope, providers class TenantId(str): ... class Dependencies(Group): tenant = providers.ContextProvider(TenantId, scope=Scope.REQUEST) repo = providers.Factory( TenantScopedRepository, # signature: (tenant: TenantId, ...) scope=Scope.REQUEST, ) container = Container(groups=[Dependencies]) with container.build_child_container( scope=Scope.REQUEST, context={TenantId: TenantId("acme")}, ) as request_container: repo = request_container.resolve(TenantScopedRepository) ``` `ContextProvider` returns the value registered for that type on the container **at the provider's own scope** — there is no global lookup like `fetch_context_item`, and [context never propagates between containers](https://modern-di.modern-python.org/providers/context/#context-propagation). For a REQUEST-scoped `ContextProvider`, pass the value to the request container via `build_child_container(context={TenantId: tenant})` or `request_container.set_context(TenantId, tenant)`. ## 6. Async resources `modern-di` resolves synchronously. There is no `AsyncFactory`, no `AsyncSingleton`, and no `await container.resolve(...)`. The pattern is **async lives in the lifespan, not in the resolve path.** Three cases cover almost everything. ### Sync creator, async finalizer The most common shape. `CacheSettings.finalizer` accepts both sync and async functions; `await container.close_async()` (which the framework integrations call automatically at shutdown) awaits the async ones. ```python import sqlalchemy.ext.asyncio def create_engine() -> sqlalchemy.ext.asyncio.AsyncEngine: return sqlalchemy.ext.asyncio.create_async_engine("postgresql+asyncpg://...") async def close_engine(engine: sqlalchemy.ext.asyncio.AsyncEngine) -> None: await engine.dispose() engine = providers.Factory( create_engine, cache=providers.CacheSettings(finalizer=close_engine), ) ``` ### Async creator (e.g. `aiohttp.ClientSession`, `await asyncpg.create_pool(...)`) `that-depends`' async `Resource`, `AsyncFactory`, and `AsyncSingleton` all map onto the same `modern-di` pattern: do the `await` in the framework's lifespan, then hand the live object to a `ContextProvider` via `set_context` so downstream factories can depend on its type. See [Async resources via lifespan](https://modern-di.modern-python.org/recipes/async-lifespan/index.md) for the full pattern, the pitfalls (setting context before yielding, combining a hand-written lifespan with an integration's `setup_di`), and which resources construct synchronously enough to skip this and just use a sync creator with an async finalizer instead. ### Per-request async construction If a per-request resource genuinely needs `await` at construction time, the simplest path is to make the *creator* sync but have it return a pre-acquired object that you placed into the request container's context. Most cases (SQLAlchemy `AsyncSession`, `httpx.AsyncClient`) can be expressed as sync creator + async finalizer instead — that path is preferred. ## 7. Lifecycle and testing ### Lifecycle - **No `init_resources()` equivalent** — providers initialize lazily on first resolve; see [Lazy initialization](https://modern-di.modern-python.org/providers/lifecycle/#lazy-initialization) for eager-warmup at startup. - **`tear_down()` / `tear_down_sync()` → `await container.close_async()` / `container.close_sync()`** (also usable as (async) context managers). The framework integrations call `close_async()` automatically at app shutdown. ### Overrides Overrides are keyed by **provider reference**, not by name: ```python # that-depends container.override_providers_sync({"decks_service": fake_decks_service}) # modern-di container.override(Dependencies.decks_service, fake_decks_service) ... container.reset_override(Dependencies.decks_service) # or reset_override() to clear all ``` See [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) for override mechanics (tree-wide sharing, reset). `modern-di-pytest` gives fixture-based wiring in place of hand-written overrides — see [the pytest integration](https://modern-di.modern-python.org/integrations/pytest/index.md). ### Validation `container.validate()` runs cycle detection and scope-chain checks. Call it explicitly at startup during migration — it catches missed scope changes and broken dependencies before the first request. ## 8. Framework integration and routes Replace `DIContextMiddleware` with the integration package's setup call ([FastAPI](https://modern-di.modern-python.org/integrations/fastapi/index.md), [Litestar](https://modern-di.modern-python.org/integrations/litestar/index.md), [FastStream](https://modern-di.modern-python.org/integrations/faststream/index.md), [Typer](https://modern-di.modern-python.org/integrations/typer/index.md)) — it creates per-request child containers, tears them down automatically, and calls `container.close_async()` at shutdown. On routes, `FromDI(T)` replaces both `fastapi.Depends(Provide[T]())` and `litestar.di.Provide`, resolving by type instead of by marker; see the integration pages for the full route examples. ## 9. No direct equivalent A handful of `that-depends` features have no direct port. Workarounds: - **`Selector`** — write a creator function that takes whatever the selector depended on and returns the chosen object. If the choice is static (e.g. one implementation per environment), `Alias` may be cleaner. - **`AttrGetter` (`provider.attr` syntax)** — resolve the parent inside the consuming creator and access the attribute there, or expose a dedicated `Factory` whose creator returns the attribute. - **`ThreadLocalSingleton`** — use `threading.local()` inside a cached `Factory`'s creator and store the per-thread object there. - **`@inject` + `Provide[T]()` for non-framework functions** — `modern-di` has no general-purpose injection decorator. Call `container.resolve(T)` explicitly at the call site, or expose the function through a framework integration and use `FromDI(T)`. ## More - Litestar usage example — [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template) - FastAPI usage example — [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template) # Migration from `dependency-injector` This guide walks an existing [`dependency-injector`](https://github.com/ets-labs/python-dependency-injector) codebase (~4.9k GitHub stars, the largest Python DI user base) through the move to `modern-di`. Every provider type documented in `dependency-injector`'s [provider catalog](https://python-dependency-injector.ets-labs.org/providers/index.html) has either a mapping below or an explicit note that there is no direct equivalent (with a workaround) — following the same rule as [the `that-depends` migration guide](https://modern-di.modern-python.org/migration/from-that-depends/index.md), the in-house template for this page. ## 1. Install Core package: ```bash uv add modern-di ``` ```bash pip install modern-di ``` ```bash poetry add modern-di ``` Framework integrations and the pytest helper live in separate packages — install only what you need: ```bash uv add modern-di-fastapi # FastAPI uv add modern-di-litestar # Litestar uv add modern-di-faststream # FastStream uv add modern-di-typer # Typer uv add modern-di-pytest # pytest fixtures ``` ```bash pip install modern-di-fastapi pip install modern-di-litestar pip install modern-di-faststream pip install modern-di-typer pip install modern-di-pytest ``` ## 2. Key conceptual shifts Three things change in how you think about the framework. Most migration confusion comes from these: - **`Group` is a schema, `Container` is the runtime.** `dependency-injector`'s `DeclarativeContainer` subclass is *both* the schema and the runtime — you instantiate it and resolve directly from it. In `modern-di`, `Group` is a namespace-only class (you cannot instantiate it) and you create the runtime `Container(groups=[MyGroup])` separately, typically once at app start. All resolution, overrides, and lifecycle calls go through that `Container` instance. - **Resolution is by type, not by marker.** `dependency-injector` has [no type-based resolution API](https://python-dependency-injector.ets-labs.org/wiring.html) — every injection point needs an explicit `Provide[Container.some_provider]` marker (or `Annotated[T, Provide[...]]`) plus `container.wire(modules=[...])` to patch it in. `modern-di` resolves by the parameter's type annotation: `container.resolve(SomeType)`, with no marker subsystem and no `wire()` step. See [§6](#6-wiring-replacement) for the failure mode this avoids. - **Scopes are an explicit, ordered hierarchy.** `dependency-injector` has no scope hierarchy — each provider independently picks a lifetime (`Factory`, `Singleton`, `Resource`, ...), and per-request state is threaded through `Resource` + the `Closing` wiring marker or a second, request-built container. `modern-di` has `Scope.APP → SESSION → REQUEST → ACTION → STEP`: a provider can only depend on providers of equal-or-broader scope, and framework integrations create the per-request child container automatically. See [§7](#7-scopes). ## 3. Provider taxonomy Use this table as the index for the rest of the guide. Every provider class documented in `dependency-injector`'s live docs is listed; "no direct equivalent" rows link to [§11](#11-no-direct-equivalent) for the workaround. | `dependency-injector` | `modern-di` replacement | Where to look | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `Factory` | `providers.Factory(...)` | [§4](#4-migrate-the-dependency-graph) | | `Callable` | `providers.Factory(the_callable)` — `Factory`'s creator can be any callable, not just a class | [§4](#4-migrate-the-dependency-graph) | | `Singleton` | `providers.Factory(..., cache=True)` | [§4](#4-migrate-the-dependency-graph) | | `ThreadSafeSingleton` | `providers.Factory(..., cache=True)` — `modern-di`'s cache is lock-guarded by default (`use_lock=True` on the container) | [§4](#4-migrate-the-dependency-graph) | | `ThreadLocalSingleton` | No direct equivalent — see [§11](#11-no-direct-equivalent) | [§11](#11-no-direct-equivalent) | | `Resource` (plain-function initializer — their docs' most common form; no shutdown step) | `providers.Factory(..., cache=True)` — same as `Singleton`; add a finalizer only when there is teardown | [§4](#4-migrate-the-dependency-graph) | | `Resource` (generator / context-manager initializer) | `providers.Factory(..., cache=CacheSettings(finalizer=...))` | [§4](#4-migrate-the-dependency-graph) | | `Resource` (async initializer) | Lifespan + `ContextProvider` (or sync creator + async finalizer) | [§4](#4-migrate-the-dependency-graph) | | `ContextLocalResource` | `providers.Factory(..., scope=Scope.REQUEST, cache=CacheSettings(finalizer=...))` resolved from a per-request child container | [§4](#4-migrate-the-dependency-graph) | | `Coroutine` | No direct equivalent — resolution is sync-only; do the `await` in the lifespan and inject the result, same as an async `Resource` | [§4](#4-migrate-the-dependency-graph) | | `Object` | `providers.Factory` with a creator that returns the value | [§4](#4-migrate-the-dependency-graph) | | `List` | `providers.Factory` with a creator that returns a list | [§4](#4-migrate-the-dependency-graph) | | `Dict` | `providers.Factory` with a creator that returns a dict | [§4](#4-migrate-the-dependency-graph) | | `Dependency` | `providers.ContextProvider(...)` | [§4](#4-migrate-the-dependency-graph) | | `AbstractFactory` | `providers.Alias(..., bound_type=...)` — pick the concrete implementation at declaration time instead of via `.override()` before first use | [§4](#4-migrate-the-dependency-graph) | | `Configuration` | A plain settings object registered as a provider — no config subsystem (`from_yaml`/`from_env`/etc.) | [§5](#5-configuration) | | `Selector` | No direct equivalent — see [§11](#11-no-direct-equivalent) | [§11](#11-no-direct-equivalent) | | `Aggregate` / `FactoryAggregate` | No direct equivalent — see [§11](#11-no-direct-equivalent) | [§11](#11-no-direct-equivalent) | | `.provided` (attribute / item / method-call access on a provider) | No direct equivalent — see [§11](#11-no-direct-equivalent) | [§11](#11-no-direct-equivalent) | | `@inject` + `Provide[...]` + `container.wire(modules=[...])` (web) | `FromDI(T)` from the framework integration | [§6](#6-wiring-replacement), [§8](#8-framework-integration-and-routes) | | `@inject` + `Provide[...]` + `container.wire(modules=[...])` (non-web) | Explicit `container.resolve(T)` | [§6](#6-wiring-replacement) | | `DeclarativeContainer` | `Group` (schema) + `Container(groups=[...])` (runtime), checked with `.validate()` | [§2](#2-key-conceptual-shifts) | | `container.init_resources()` | Lazy initialization — no equivalent needed | [§9](#9-testing-and-overrides) | | `container.shutdown_resources()` / `provider.shutdown()` | `container.close_sync()` / `await container.close_async()` | [§9](#9-testing-and-overrides) | | `provider.override(...)` / `with provider.override(...):` | `container.override(provider, mock)` / `with container.override(provider, mock):` — see [§9](#9-testing-and-overrides) | [§9](#9-testing-and-overrides) | | `provider.reset_override()` / `provider.reset_last_overriding()` | `container.reset_override(provider)` | [§9](#9-testing-and-overrides) | ## 4. Migrate the dependency graph 1. Replace `DeclarativeContainer` with `Group`. 1. Add an explicit `scope=` to each provider (defaults to `Scope.APP`). 1. Create the runtime container with `Container(groups=[MyGroup])`, then call `container.validate()` for whole-graph checks. In `modern-di`, `Group` is a schema only — you cannot resolve from it directly, unlike a `DeclarativeContainer` instance. **`Singleton` / `ThreadSafeSingleton`** → `providers.Factory(SomeClass, cache=True)` — no separate thread-safe class, since `modern-di`'s cache is lock-guarded by default. See [Cached factories](https://modern-di.modern-python.org/providers/factories/#cached-factories). **`Resource`** → cached `Factory`, with or without a `finalizer` depending on the initializer form. Their docs call the plain-function initializer "the most common way to specify resource initialization" — and a plain-function `Resource` has no shutdown step, so it maps to exactly what `Singleton` maps to: ```python # dependency-injector — plain-function initializer, no shutdown thread_pool = providers.Resource(init_thread_pool, max_workers=4) # modern-di — same as the Singleton mapping thread_pool = providers.Factory(init_thread_pool, kwargs={"max_workers": 4}, cache=True) ``` For the generator or context-manager initializer forms (the ones with a shutdown step), split init and teardown into a plain creator function and a separate finalizer function: ```python # dependency-injector def init_resource(argument1=...): resource = SomeResource() # initialization yield resource # shutdown code thread_pool = providers.Resource(init_resource) # modern-di def create_resource() -> SomeResource: return SomeResource() def close_resource(resource: SomeResource) -> None: ... # shutdown code thread_pool = providers.Factory( create_resource, cache=providers.CacheSettings(finalizer=close_resource), ) ``` **`ContextLocalResource`** → `REQUEST`-scoped cached `Factory` with a `finalizer`. `dependency-injector`'s `ContextLocalResource` uses `contextvars` to give each execution context (in practice: each async request) its own instance of a `Resource`, cleaned up when the context ends. `modern-di` expresses the same lifetime explicitly: declare the provider at `Scope.REQUEST` and resolve it from a per-request child container — the framework integrations build that child container for you ([§8](#8-framework-integration-and-routes)), and closing it runs the finalizer: ```python # dependency-injector db_session = providers.ContextLocalResource(AsyncSessionLocal) # modern-di — one instance per request container, finalizer on request end db_session = providers.Factory( create_session, scope=Scope.REQUEST, cache=providers.CacheSettings(finalizer=close_session), ) ``` **`Callable`** → a plain `Factory` whose creator is the callable — `modern-di` has no separate "wraps a function vs. wraps a class" distinction; `Factory.creator` accepts any `Callable[..., T]`. Note the call-time argument this example passes (`container.password_hasher("super secret")`) has no `modern-di` equivalent — see the note below: ```python # dependency-injector password_hasher = providers.Callable(passlib.hash.sha256_crypt.hash, salt_size=16, rounds=10000) hashed = container.password_hasher("super secret") # "super secret" supplied at call time # modern-di — the value must be static (kwargs) or itself a resolvable dependency password_hasher = providers.Factory( passlib.hash.sha256_crypt.hash, kwargs={"secret": "super secret", "salt_size": 16, "rounds": 10000}, ) ``` > **Providers are not partially-applied callables in `modern-di`.** In `dependency-injector`, every provider instance is itself callable, and calling it with extra positional/keyword arguments merges them with the declared ones for that one call (`container.some_factory(extra_arg)`). `modern-di`'s `Factory` has no equivalent: `resolve()`/`resolve_provider()` take no arguments, and every constructor argument must be resolvable (by type, by `kwargs`, or by default) at declaration time. If a value genuinely varies per call site, resolve a plain function or make it a `ContextProvider`/`Scope.REQUEST` dependency instead of trying to pass it at the call site. **`Object`** → `Factory` whose creator returns the value. Define a small typed function (lambdas have no return annotation, which prevents resolution by type): ```python # dependency-injector object_provider = providers.Object("secret-token") # modern-di class ApiKey(str): ... def _api_key() -> ApiKey: return ApiKey("secret-token") api_key = providers.Factory(_api_key, cache=True) ``` If you only need the value passed into one downstream provider, skip the wrapper and put it directly in that provider's `kwargs`. **`List` / `Dict`** → `Factory` with a creator that builds the collection: ```python # dependency-injector modules = providers.List( providers.Factory(Module, name="m1"), providers.Factory(Module, name="m2"), ) # modern-di def build_modules() -> list[Module]: return [Module("m1"), Module("m2")] modules = providers.Factory(build_modules) ``` **`Dependency`** → `ContextProvider`. Both are a typed placeholder filled in at runtime rather than constructed by a factory: ```python # dependency-injector database = providers.Dependency(instance_of=DbAdapter) # container = Container(database=providers.Singleton(SqliteDbAdapter)) # modern-di database = providers.ContextProvider(DbAdapter, scope=Scope.APP) # container = Container(groups=[AppGroup], context={DbAdapter: SqliteDbAdapter()}) ``` **`AbstractFactory`** → `Alias`. `dependency-injector`'s `AbstractFactory` starts unbound and must be `.override()`-ed with a concrete `Factory` before first use; `modern-di` instead registers the concrete provider directly and re-exports it under the abstract type at declaration time — no override step, and `validate()` catches a missing binding before the first resolve: ```python # dependency-injector cache_client_factory = providers.AbstractFactory(AbstractCacheClient) # container.cache_client_factory.override(providers.Factory(RedisCacheClient, host="localhost")) # modern-di redis_cache_client = providers.Factory(RedisCacheClient, cache=True) cache_client = providers.Alias(RedisCacheClient, bound_type=AbstractCacheClient) ``` ## 5. Configuration `dependency-injector`'s `Configuration` provider is a subsystem: `providers.Configuration()` plus `.from_yaml()` / `.from_json()` / `.from_ini()` / `.from_env()` / `.from_pydantic()` / `.from_dict()` / `.from_value()` loaders, environment-variable interpolation (`${VAR:default}`), and a "use first, define later" declaration order. `modern-di` deliberately has no equivalent subsystem — this is a design decision, not a gap: load your settings with whatever library you already use (`pydantic-settings`, `environ-config`, plain `os.environ`, ...) into a regular object, then register that object as an ordinary provider: ```python class Settings: def __init__(self) -> None: self.database_url = os.environ["DATABASE_URL"] class AppGroup(Group): settings = providers.Factory(Settings, cache=True) ``` If a value needs to be supplied by the caller rather than computed (e.g. it comes from a CLI flag or a request header), use `ContextProvider` instead — see [§4](#4-migrate-the-dependency-graph)'s `Dependency` mapping. ## 6. Wiring replacement `dependency-injector` requires three cooperating pieces for every injection point: the `@inject` decorator (must be the outermost decorator), a `Provide[Container.provider]` or `Annotated[T, Provide[Container.provider]]` default value, and an explicit `container.wire(modules=[...])` call that patches the marked functions at import time. `modern-di` has no marker subsystem: it resolves by matching a parameter's *type annotation* against the registry, so there is nothing to wire. ```python # dependency-injector from dependency_injector.wiring import Provide, inject @inject def process(service: Service = Provide[Container.service]) -> None: ... container = Container() container.wire(modules=[__name__]) ``` ```python # modern-di — outside a framework: resolve explicitly at the call site service = container.resolve(Service) process(service) ``` ```python # modern-di — inside a web framework: FromDI(T) replaces Provide[Container.x] from modern_di_fastapi import FromDI @ROUTER.get("/") async def handler(service: Service = FromDI(Service)) -> None: ... ``` More framework examples in [§8](#8-framework-integration-and-routes). This also removes `dependency-injector`'s most-filed failure mode: an unwired function's marker is left as a raw `Provide` object, which surfaces as a confusing `AttributeError: 'Provide' object has no attribute ...` deep in your own code ([issue #658](https://github.com/ets-labs/python-dependency-injector/issues/658), [issue #521](https://github.com/ets-labs/python-dependency-injector/issues/521)) rather than a DI-specific error at the point of the mistake. `modern-di` fails at declaration time (`UnsupportedCreatorParameterError`) or resolve time (`ProviderNotRegisteredError`, with "did you mean" suggestions) — see [§10](#10-diagnostics-comparison). ## 7. Scopes `dependency-injector` has no ordered scope hierarchy. Each provider independently chooses a lifetime class (`Factory` = new object every call, `Singleton`/`ThreadSafeSingleton` = one object per container, `Resource` = one object with init/shutdown hooks), and request-scoped state is either threaded through the `Closing` wiring marker on a `Resource` or built with a second, request-scoped container instantiated per request. `modern-di` has one mechanism for both "create once" and "scoped to a boundary": `Scope.APP → SESSION → REQUEST → ACTION → STEP`, plus child containers. ```python class AppGroup(Group): # one instance for the whole app's lifetime db_pool = providers.Factory(create_pool, scope=Scope.APP, cache=True) # one instance per request; built by build_child_container(scope=Scope.REQUEST) current_user = providers.Factory(UserFromRequest, scope=Scope.REQUEST) app_container = Container(scope=Scope.APP, groups=[AppGroup]) request_container = app_container.build_child_container(scope=Scope.REQUEST, context={...}) ``` See [the scope dependency rule](https://modern-di.modern-python.org/providers/scopes/#the-scope-dependency-rule) for the equal-or-broader constraint and how `validate()` catches a violation before the first resolve. Framework integrations ([§8](#8-framework-integration-and-routes)) build and tear down the per-request child container automatically, the same role `Resource` + `Closing` (or a hand-rolled second container) plays in `dependency-injector`. ## 8. Framework integration and routes Replace `container.wire(modules=[...])` (plus any per-framework glue such as `container` attributes on the app object) with the integration package's setup call ([FastAPI](https://modern-di.modern-python.org/integrations/fastapi/index.md), [Litestar](https://modern-di.modern-python.org/integrations/litestar/index.md), [FastStream](https://modern-di.modern-python.org/integrations/faststream/index.md), [Typer](https://modern-di.modern-python.org/integrations/typer/index.md)) — it creates per-request child containers, tears them down automatically, and calls `container.close_async()` at shutdown. There is no module list to maintain and no import-time patching. On routes, `FromDI(T)` replaces the `@inject` + `Provide[Container.x]` pair: resolution is by type, so no marker points at a specific container attribute and no `@inject` decorator is needed — see the integration pages for the full route examples. ## 9. Testing and overrides ### Overrides Overrides are keyed by **provider reference**, not attribute name, same idea as `dependency-injector` but through the container rather than the provider object: ```python # dependency-injector container.api_client_factory.override(unittest.mock.Mock(ApiClient)) ... container.api_client_factory.reset_override() # modern-di container.override(AppGroup.api_client_factory, unittest.mock.Mock(ApiClient)) ... container.reset_override(AppGroup.api_client_factory) # or reset_override() to clear all ``` `dependency-injector` also has a context-manager override form (`with container.api_client_factory.override(mock):`) that auto-resets on exit. `modern-di` has the same shape — `with container.override(provider, mock) as m:` applies the override for the block and restores the prior state on exit, including on exception: ```python # modern-di with container.override(AppGroup.api_client_factory, unittest.mock.Mock(ApiClient)) as mock_factory: ... ``` See [Testing with overrides](https://modern-di.modern-python.org/recipes/testing-overrides/index.md) for tree-wide sharing, nesting, and reset mechanics. ### Lifecycle - **No `init_resources()` equivalent** — providers initialize lazily on first resolve; see [Lazy initialization](https://modern-di.modern-python.org/providers/lifecycle/#lazy-initialization) for eager-warmup at startup. - **`shutdown_resources()` / `provider.shutdown()` → `container.close_sync()` / `await container.close_async()`** (also usable as (async) context managers, finalizers running in reverse order on exit). ### Pytest `modern-di-pytest` provides fixture-based wiring, replacing hand-written `container.override(...)` calls per test — see [the pytest integration](https://modern-di.modern-python.org/integrations/pytest/index.md). ## 10. Diagnostics comparison | Failure mode | `dependency-injector` | `modern-di` | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Circular dependency | No cycle detection; a circular provider graph raises a bare `RecursionError` from Cython-level `deepcopy`, with no cycle path ([issue #811](https://github.com/ets-labs/python-dependency-injector/issues/811)) | `validate()` reports every cycle up front as `CircularDependencyError` with an arrow-chain `cycle_path`; even without `validate()`, a runtime cycle hit is caught and re-raised as `CircularDependencyError` (not a bare `RecursionError`) | | Unwired injection point | Silent: an un-wired function keeps the raw `Provide` marker as its default, surfacing as `AttributeError: 'Provide' object has no attribute ...` far from the actual mistake ([#658](https://github.com/ets-labs/python-dependency-injector/issues/658), [#521](https://github.com/ets-labs/python-dependency-injector/issues/521)) | No marker subsystem to leave unwired: a missing dependency fails at declaration time (`UnsupportedCreatorParameterError`) or resolve time (`ProviderNotRegisteredError`, `ArgumentResolutionError`) | | Whole-graph validation | None — errors surface one at a time, on first resolve, wherever the graph happens to break | `container.validate()` walks the entire graph and raises one `ValidationFailedError` aggregating *every* wiring bug (cycles, inverted scopes, missing dependencies) at once | | Resolve by type | [No type-based resolution API](https://python-dependency-injector.ets-labs.org/wiring.html) — every call site needs an explicit `Provide[Container.x]` marker | `container.resolve(SomeType)` resolves directly from a type annotation; unregistered types get closest-match ("did you mean") suggestions | Call `container.validate()` explicitly during migration — the cycle row above is considerably noisier without it, since the error surfaces deep inside an already near-exhausted call stack instead of a clean, aggregated report. ## 11. No direct equivalent A handful of `dependency-injector` features have no direct port. Workarounds: - **`ThreadLocalSingleton`** — use `threading.local()` inside a cached `Factory`'s creator and store the per-thread object there. - **`Selector`** — write a creator function that takes whatever the selector depended on and returns the chosen object. If the choice is static (e.g. one implementation per environment), `Alias` may be cleaner. - **`Aggregate` / `FactoryAggregate`** — resolve each candidate provider individually (by type or by reference) and dispatch on the key yourself in a small creator function, rather than injecting the whole aggregate object. - **`.provided` (attribute / item / method-call access on a provider, e.g. `service.provided.value`)** — resolve the parent inside the consuming creator and access the attribute, item, or method result there, or expose a dedicated `Factory` whose creator returns just that piece. - **`@inject` + `Provide[T]()` for non-framework functions** — `modern-di` has no general-purpose injection decorator. Call `container.resolve(T)` explicitly at the call site, or expose the function through a framework integration and use `FromDI(T)`. - **Call-time provider arguments** (`container.some_factory(extra_arg)` merging extra args into that one call) — `modern-di` providers resolve with no arguments; move the varying value into `kwargs=` if it is static, or into a `ContextProvider`/deeper-scoped dependency if it genuinely varies per call site. ## More - [modern-di vs dependency-injector](https://modern-di.modern-python.org/introduction/comparison/#vs-dependency-injector) — the short, non-migration-focused comparison. - Litestar usage example — [litestar-sqlalchemy-template](https://github.com/modern-python/litestar-sqlalchemy-template) - FastAPI usage example — [fastapi-sqlalchemy-template](https://github.com/modern-python/fastapi-sqlalchemy-template) # Development # Contributing This is an open source project, and we are open to new contributors. ## Getting started 1. Make sure that you have [uv](https://docs.astral.sh/uv/) and [just](https://just.systems/) installed. 1. Clone project: ```text git clone git@github.com:modern-python/modern-di.git # or: git clone https://github.com/modern-python/modern-di.git cd modern-di ``` 1. Install dependencies by running `just install` ## Running linters `Ruff` and `ty` are used for static analysis. Run all checks by command `just lint` ## Running tests Run all tests by command `just test`. Run a subset with `just test -k `. CI runs the coverage-enforcing recipe `just test-ci` along with `just lint-ci`. ## Submitting changes 1. Fork the repo and branch off `main`. 1. Make your change with tests; keep **100% line coverage** (CI runs `just test-ci` with `--cov-fail-under=100`). 1. Run `just lint` and `just test` locally before pushing (CI runs the non-fixing variants `just lint-ci` / `just test-ci`). 1. For non-trivial changes, the PR body is the spec — the pull-request template walks you through it (why, design, non-goals, verification). 1. Open a pull request upstream.