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 mapping below or a note that there is no direct equivalent, with a workaround.
1. Install¶
Core package:
Framework integrations and the pytest helper live in separate packages. These cover the frameworks that-depends integrates with:
There are also integrations for aiohttp, Starlette, Flask, Celery, arq, taskiq, gRPC, aiogram, FastMCP and Typer, each installed as modern-di-<name>.
2. Key conceptual shifts¶
Three things change in how you think about the framework. Most migration confusion comes from these:
Groupis a schema andContaineris the runtime. Inthat-depends, aBaseContainersubclass is both the schema and the runtime: you resolve directly from the class. Inmodern-di,Groupis a namespace-only class (you cannot instantiate it) and you create the runtimeContainer(groups=[MyGroup])separately, typically once at app start. All resolution, overrides, and lifecycle calls go through thatContainerinstance.- Resolution is sync-only.
modern-didoes not haveAsyncFactory,AsyncSingleton, orawait container.resolve(...). Async work happens in the framework's lifespan (see §6), and finalizers may be async. Design decisions explains why there are no plans to add async resolution. - Scopes are ordered.
that-dependshas named scopes (ContextScopes.APP,REQUESTand so on) thatcontainer_context(scope=...)enters.modern-discopes form a chain,Scope.APP → SESSION → REQUEST → ACTION → STEP, and a provider may depend only on providers of its own or a broader scope.container.validate()enforces the scope dependency rule. Framework integrations create the per-request child container for you.
3. Provider mapping¶
Use this table as the index for the rest of the guide.
that-depends |
modern-di replacement |
Where to look |
|---|---|---|
BaseContainer |
Group (schema) + Container(groups=[...]) (runtime) |
§4 |
BaseContainer.connect_containers(A, B) |
One Container(groups=[A, B]); closing it tears down every group |
§4 |
Factory |
providers.Factory(...) |
§4 |
Singleton |
providers.Factory(..., cache=True) |
§4 |
Resource (sync gen / ctx mgr) |
providers.Factory(..., cache=CacheSettings(finalizer=...)) |
§4 |
Resource (async gen / ctx mgr) |
Lifespan + ContextProvider (or sync creator + async finalizer) |
§6 |
ContextResource |
providers.Factory(..., scope=Scope.REQUEST, cache=CacheSettings(finalizer=...)) |
§5 |
AsyncFactory |
Lifespan-managed; expose via ContextProvider |
§6 |
AsyncSingleton |
Lifespan-managed; expose via ContextProvider |
§6 |
Object |
providers.Factory with a creator that returns the value |
§4 |
List |
providers.Factory with a creator that returns a list |
§4 |
Dict |
providers.Factory with a creator that returns a dict |
§4 |
Selector |
No direct equivalent | §9 |
AttrGetter (provider.attr) |
provider.derive(lambda value: value.attr) |
Derived values |
ThreadLocalSingleton |
No direct equivalent | §9 |
State |
ContextProvider + set_context |
§5 |
provider.bind(T) |
providers.Factory(..., bound_type=T); add an Alias per extra type |
§4 |
ContextScopes / default_scope |
Scope / class MyGroup(Group, scope=...) |
§5 |
@inject + Provide[Container.x], Provide() or Provide["Container.x"] (web) |
FromDI(T) from the framework integration |
§8 |
fastapi.Depends(Container.x) / litestar.di.Provide(Container.x) |
FromDI(T) from the framework integration |
§8 |
@inject + Provide[...] (non-web) |
Explicit container.resolve(T) |
§9 |
experimental.LazyProvider |
Not needed: type-based wiring never imports the provider | §4 |
BaseContainer.resolve(func) / resolver(func) |
Register func as a Factory and resolve that |
§9 |
container_context() |
container.build_child_container(scope=..., context=...) |
§5 |
DIContextMiddleware |
The integration's setup call, such as setup_di(app, container) or ModernDIPlugin(container) |
§8 |
fetch_context_item / _by_type |
ContextProvider(T) |
§5 |
init_resources() |
Lazy initialization; no equivalent needed | §7 |
tear_down() / tear_down_sync() |
await container.close_async() / container.close_sync() |
§7 |
with Container.override_providers_sync({...}): |
One with container.override(provider, mock): per provider |
§7 |
provider.override_sync(mock) |
container.override(provider, mock) |
§7 |
with provider.override_context_sync(mock): |
with container.override(provider, mock): |
§7 |
4. Migrate the dependency graph¶
- Replace
BaseContainerwithGroup. - Give each provider that is not app-wide a
scope=. A provider without one isScope.APP. For a group whose providers mostly share a scope, set a group-level default withclass Dependencies(Group, scope=Scope.REQUEST), the waydefault_scopeworks inthat-depends. - Create the runtime container with
Container(groups=[MyGroup]). Inmodern-di, theGroupclass is a schema only; you cannot resolve from it directly. Containers you joined withconnect_containersbecome several groups in oneContainer.
A provider passed inside kwargs={...} is resolved like any other dependency. There is no .cast indirection, so drop those calls. A creator parameter with a type annotation needs no kwargs entry at all: modern-di wires it to the provider registered for that type. Below, create_sa_engine(settings: Settings) gets the cached Settings that way, and session passes the engine by reference to show the other form.
from that_depends import BaseContainer, providers
from app import repositories
from app.resources.db import create_sa_engine, create_session
from app.settings import Settings
class Dependencies(BaseContainer):
settings = providers.Singleton(Settings)
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)
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
from app.settings import Settings
class Dependencies(Group):
settings = providers.Factory(Settings, cache=True)
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},
)
container = Container(groups=[Dependencies])
Type-based wiring also replaces that-depends' other ways of pointing at a provider. Provide() with a bound type, Provide["Container.provider"] strings and experimental.LazyProvider all exist to reference a provider without importing it. A modern-di creator names the type it needs and never imports the provider.
Per-provider replacements¶
Replace Singleton with a cached Factory of APP scope:
# that-depends
some_singleton = providers.Singleton(SomeClass)
# modern-di
some_singleton = providers.Factory(
SomeClass,
cache=True,
)
Replace a sync Resource (sync generator or context manager) with a cached Factory that has a finalizer, splitting the generator into a creator and a finalizer function. See database_engine in the worked example above.
Replace Object with a Factory whose creator returns the value. Define a small typed function, because a lambda has no return annotation and so gets no type to resolve by:
# 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.
Replace List and Dict with a Factory whose creator builds the collection, and pass that provider to its consumers through kwargs:
# 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)
consumer = providers.Factory(Consumer, kwargs={"items": some_list})
A parameterized return type registers under its bare origin: build_list is bound to list, not list[object], so container.resolve(list[object]) raises ProviderNotRegisteredError. A consumer parameter annotated list[X] cannot be wired by type either, and Factory(Consumer) raises UnsupportedCreatorParameterError unless kwargs supplies it, as above. Two list-returning factories in one container both claim list and raise DuplicateProviderTypeError; give all but one bound_type=None and pass them by reference.
Replace provider.bind(Repository) with bound_type=. bind replaces the provider's bindings, so the provider answers only for Repository, and bound_type does the same:
# that-depends
repo = providers.Factory(PostgresRepository).bind(Repository)
# modern-di
repo = providers.Factory(PostgresRepository, bound_type=Repository)
When the provider must answer for more than one type, as with .bind(A, B) or contravariant=True, keep it bound to one type and add an Alias for each other type:
repo = providers.Factory(PostgresRepository)
as_repository = providers.Alias(PostgresRepository, bound_type=Repository)
as_audit_repository = providers.Alias(PostgresRepository, bound_type=AuditRepository)
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.
A ContextResource is one instance per context with teardown when the context exits. In modern-di that is a cached Factory at Scope.REQUEST with a finalizer, like session in the worked example: each request container builds one instance and runs the finalizer when it closes. When a framework integration builds the per-request child container, declaring the scope is all you do. container_context()'s manual case maps onto using that child container as a context manager yourself. See Building child containers for both forms.
ContextScopes.APP and ContextScopes.REQUEST map to Scope.APP and Scope.REQUEST, and default_scope maps to a group-level default scope (§4). ContextScopes.ANY and ContextScopes.INJECT have no direct equivalent. The nearest is a child container you build and close around the call that needs it. For scopes of your own, see Custom scopes.
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:
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. 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(...). Async work lives in the lifespan, not in the resolve path. Three cases cover almost everything.
An async def creator is not rejected
providers.Factory(create_pool) accepts an async def creator. Resolving it returns the
coroutine object, never awaited, and the only sign is a RuntimeWarning: coroutine ... was
never awaited when that object is garbage-collected. Porting an AsyncFactory by swapping the
class name produces exactly this, so move each async creator to one of the shapes below.
Sync creator, async finalizer¶
The most common shape. CacheSettings.finalizer accepts both sync and async functions, and await container.close_async() awaits the async ones. close_sync() cannot run an async finalizer and raises FinalizerError.
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 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 use a
sync creator with an async finalizer instead.
Per-request async construction¶
Most per-request resources, such as SQLAlchemy's AsyncSession or httpx.AsyncClient, construct synchronously, so a sync creator with an async finalizer covers them. When one really needs await to construct, do the await in a per-request hook or middleware, then put the result on that request's container with set_context, for a ContextProvider at Scope.REQUEST:
class Dependencies(Group):
connection = providers.ContextProvider(Connection, scope=Scope.REQUEST)
repo = providers.Factory(Repository, scope=Scope.REQUEST) # takes connection: Connection
async with container.build_child_container(scope=Scope.REQUEST) as request_container:
request_container.set_context(Connection, await acquire_connection())
repo = request_container.resolve(Repository)
7. Lifecycle and testing¶
Lifecycle¶
- There is no
init_resources()equivalent: providers initialize lazily on first resolve; see Lazy initialization for eager-warmup at startup. tear_down()/tear_down_sync()becomeawait container.close_async()/container.close_sync(), also usable as (async) context managers. Which integrations close the APP container for you is in §8.
Overrides¶
Overrides are keyed by provider reference, not by name. container.override(...) returns a handle that also works as a context manager and restores the previous state on exit, including on an exception:
# that-depends
with Container.override_providers_sync({"decks_service": fake_decks_service}):
...
# modern-di, for one block
with container.override(Dependencies.decks_service, fake_decks_service):
...
# modern-di, until reset
container.override(Dependencies.decks_service, fake_decks_service)
...
container.reset_override(Dependencies.decks_service) # or reset_override() to clear all
See Testing with overrides for override mechanics (tree-wide sharing, reset). modern-di-pytest gives fixture-based wiring in place of hand-written overrides; see the pytest integration.
Validation¶
Call container.validate() at startup during the migration. It walks the whole graph and raises one ValidationFailedError listing every problem it finds: cycles, a provider that depends on a narrower scope, creator arguments nothing can supply, and aliases whose source is not registered. It does not check that context values are set; a missing one surfaces at resolve as ContextValueNotSetError. With a framework integration, call it after the setup call, which registers the integration's own providers, such as the one for the framework's request object.
8. Framework integration and routes¶
Replace DIContextMiddleware with the integration package's setup call, such as setup_di(app, container) for FastAPI and FastStream or ModernDIPlugin(container) for Litestar. Integrations build a child container for each request or message and close it when the request ends. Most also close the APP container at shutdown. Flask, gRPC and Typer leave the APP container to you, and Typer builds its per-command container in @modern_di_typer.inject, not in its setup call; see Closing the container. Each integration page says which applies.
On routes, FromDI(T) replaces both fastapi.Depends(Container.provider) and litestar.di.Provide(Container.provider), resolving by type instead of by provider reference; see the integration pages for the full route examples.
9. No direct equivalent¶
A handful of that-depends features have no direct port. Workarounds:
- For
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),Aliasmay be cleaner. - For
ThreadLocalSingleton, register an uncachedFactorywhose creator reads the object from a module-levelthreading.local()and creates and stores it there on a thread's first call. A cachedFactorycan't do this, because it caches one object for the whole container. modern-dihas no general-purpose injection decorator to replace@inject+Provide[...]on non-framework functions. Callcontainer.resolve(T)explicitly at the call site, or expose the function through a framework integration and useFromDI(T).- For
BaseContainer.resolve(func)andresolver(func), which callfuncwith its parameters filled by name, registerfuncas aFactorywithbound_type=Noneand callcontainer.resolve_provider(...)on it. Its parameters are then wired by type, not by name.
More¶
- Litestar usage example: litestar-sqlalchemy-template
- FastAPI usage example: fastapi-sqlalchemy-template