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.statebag pluslru_cachewith no cleanup. - You resolve dependencies off the request path: in startup, background tasks,
workers, or CLI commands, where
Depends/Providedon'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.