Skip to content

modern-di vs other libraries

This page covers where modern-di fits among the other ways to do dependency injection in Python, 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:

  • You have more than one entrypoint: an API and a worker (FastStream/Celery) and a CLI (Typer) can share one wiring instead of three parallel copies.
  • You want typed, app-scoped singletons with real teardown, where the usual alternative is an untyped app.state bag plus lru_cache with no cleanup.
  • You resolve dependencies off the request path: in startup, background tasks, workers, or CLI commands, where Depends/Provide don't run.
  • You want whole-app test overrides: swap a dependency once and every entrypoint (HTTP, worker, CLI, direct unit tests) sees it, including code the HTTP layer never reaches.

modern-di covers those cases with one typed wiring shared across twelve frameworks: aiohttp, FastAPI, Litestar, FastStream, Starlette, Typer, Flask, gRPC, Celery, arq, taskiq, and aiogram.

Feature comparison

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 n/a
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 deliberate: 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.

Library by library

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 and 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) turns any dependency into a fixture. Dishka ships no pytest plugin and documents a hand-written fixtures recipe instead.
  • Resolution is sync-only (async finalizers are still supported), and the built-in scope chain is small, though you can extend it with any IntEnum. Dishka's own docs note that custom scopes are "hardly ever needed," which is the case for modern-di's simpler design. See Custom scopes.
  • Every integration is official and uniformly maintained under a single MIT-licensed project, part of the broader 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. To migrate an existing codebase, see the migration guide 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 has built-in scopes, official framework integrations, and resource finalization.

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? above. Reach for modern-di once you have a second entrypoint, or need typed, scoped, app-wide singletons with overrides that also apply off the HTTP path.

that-depends or modern-di?

that-depends is a sibling project from the same author, in the same modern-python family. It isn't in the table above because choosing between the two is mostly a matter of which generation of the same design you want.

  • For 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.
  • If you already use that-depends, it remains actively maintained and production-proven, so you don't need to migrate. Move when you want explicit scopes or a no-global-state architecture; the migration guide 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 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. The table translates six lifetime concepts from other frameworks:

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)
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(...) 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)
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(...) + context={...}
Interface binding (concrete → abstract type) providers.AbstractFactory, which 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)
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)

See also

  • Design decisions: the reasoning behind sync-only resolution, no global state, a conservative core, and the deliberate non-goals that keep it that way.
  • Performance: comparative benchmarks of how fast resolution is versus other DI frameworks, and the method behind the numbers.