Usage¶
fastapi-standalone-di centres on one object: the
FastAPIContainer. It holds the configuration
needed to resolve a FastAPI dependency tree without a running ASGI application,
and owns the lifetime of the instances it builds.
Resolving dependencies outside ASGI¶
The container asks FastAPI for the dependency tree of your callable
(fastapi.dependencies.utils.get_dependant), resolves sub-dependencies
recursively, and invokes each callable with the right execution model
(coroutine, sync function in a threadpool, sync/async generator via an
AsyncExitStack). Every resolution entry point is asynchronous:
import asyncio
from fastapi import Depends
from fastapi_standalone_di import FastAPIContainer
class Config:
url = "postgres://localhost/app"
class Database:
def __init__(self, config: Config = Depends(Config)) -> None:
self.config = config
async def main() -> None:
async with FastAPIContainer() as container:
# get() returns one instance directly:
db = await container.get(Database)
assert db.config.url == "postgres://localhost/app"
# resolve() takes several dependencies and returns a bag:
deps = await container.resolve(Database, Config)
assert deps.get(Database) is db
assert deps.get(Config) is db.config
asyncio.run(main())
resolve() returns a ResolvedDependencies
whose get(dep) / optional(dep) address the dependencies you explicitly asked
for. The sub-dependencies resolved along the way are captured too: reach them by
passing transitive=True, or iterate over the complete set with
all_instances() (a read-only mapping, ordered sub-dependencies first). The
instances returned are exactly those wired into their dependents.
import asyncio
from fastapi import Depends
from fastapi_standalone_di import FastAPIContainer
class Config:
url = "postgres://localhost/app"
class Database:
def __init__(self, config: Config = Depends(Config)) -> None:
self.config = config
class Service:
def __init__(self, db: Database = Depends(Database)) -> None:
self.db = db
async def main() -> None:
async with FastAPIContainer() as container:
deps = await container.resolve(Service)
# get()/optional() address only the dependency you asked for:
service = deps.get(Service)
# sub-dependencies resolved along the way need transitive=True:
db = deps.get(Database, transitive=True)
config = deps.optional(Config, transitive=True)
assert db is service.db
assert config is service.db.config
# or iterate over every instance that was built:
assert set(deps.all_instances()) == {Service, Database, Config}
asyncio.run(main())
Invoking an entry point¶
invoke(fn) resolves fn's Depends() parameters and calls it. Unlike
resolve/get, the entry point itself is not cached — it is treated as a
one-shot call. It runs inside an implicit resolution scope, so any SCOPED
dependency it uses is torn down once fn returns (see
Dependency scopes).
import asyncio
from fastapi import Depends
from fastapi_standalone_di import FastAPIContainer
def get_greeting() -> str:
return "hello"
async def handler(greeting: str = Depends(get_greeting)) -> str:
return greeting.upper()
async def main() -> None:
async with FastAPIContainer() as container:
assert await container.invoke(handler) == "HELLO"
asyncio.run(main())
invoke_resolved(fn) gives the same result plus the resolved bag: get(fn) is
the invocation result, and the sub-dependencies are exposed the same way as with
resolve.
Caching¶
Resolved instances are cached, keyed by the resolved callable, within the scope
that owns them. The default scope is the container itself, so get,
resolve and their sub-dependencies reuse the same instance across every call
until await container.aclose() — or container.clear_cache(), which drops the
cached instances without running teardown.
Caching is per injection within a scope, so two consumers of the same
dependency share one instance. FastAPI's use_cache=False on a Depends(...)
opts that dependency out: it is rebuilt fresh at each injection point, while its
yield teardown still runs on its scope's exit stack.
yield dependencies and teardown¶
Generator dependencies (sync or async) are supported. Their teardown runs when
the owning scope closes — for CONTAINER scope, that is when the container is
closed. Use the container as an async context manager, or call
await container.aclose():
import asyncio
from collections.abc import AsyncIterator
from fastapi_standalone_di import FastAPIContainer
class Client:
async def close(self) -> None: ...
async def get_client() -> AsyncIterator[Client]:
client = Client()
try:
yield client
finally:
await client.close() # runs on container exit
async def main() -> None:
async with FastAPIContainer() as container:
client = await container.get(get_client)
assert isinstance(client, Client)
# client.close() has run here
asyncio.run(main())
Each dependency is entered on the AsyncExitStack of its scope — the container's
for CONTAINER, the resolution scope's for SCOPED — so teardown always runs in
reverse resolution order when that scope closes.
Overriding dependencies¶
Pass dependency_overrides to swap an implementation, mirroring FastAPI's
app.dependency_overrides. This is the idiomatic way to inject test doubles:
import asyncio
from fastapi import Depends
from fastapi_standalone_di import FastAPIContainer
def get_settings() -> dict[str, str]:
return {"db_url": "postgres://localhost/app"}
class Database:
def __init__(self, settings: dict[str, str] = Depends(get_settings)) -> None:
self.url = settings["db_url"]
async def main() -> None:
container = FastAPIContainer(
dependency_overrides={get_settings: lambda: {"db_url": "sqlite://"}},
)
db = await container.get(Database)
assert db.url == "sqlite://"
asyncio.run(main())
Registrable interfaces¶
RegistrableDependency lets you declare an
interface (an abstract base) and bind its concrete implementation elsewhere,
then depend on the interface. The container dereferences the interface to its
registered implementation automatically:
import asyncio
from abc import ABC, abstractmethod
from fastapi_standalone_di import FastAPIContainer, RegistrableDependency
class IClock(ABC, RegistrableDependency):
@abstractmethod
def now(self) -> str: ...
class SystemClock(IClock):
def now(self) -> str:
return "2026-07-02"
IClock.register(SystemClock)
async def main() -> None:
container = FastAPIContainer()
clock = await container.get(IClock) # -> SystemClock instance
assert clock.now() == "2026-07-02"
asyncio.run(main())
Register with IClock.register(SystemClock), clear it with
IClock.register(None). Resolving an interface with no implementation registered
raises RuntimeError.
Patching FastAPI's Depends
The container resolves the interface indirection on its own. You only need
patch_for_registrable_dependency_support()
when FastAPI itself must see the concrete implementation at introspection
time (e.g. for OpenAPI generation inside a real app). It patches the
Depends class in place, so it affects every Depends() object regardless
of when it was created — calling it once at import time is enough and its
order relative to your @singleton/Depends() definitions does not matter.
Discovering bindings¶
In a feature-oriented codebase, each feature binds its interfaces to their
implementations in a per-feature di module:
# myapp/features/orders/di.py
def register() -> None:
OrderService.register(DefaultOrderService)
OrderRepository.register(SqlOrderRepository)
Because FastAPI resolves a route's full Depends(...) tree at decoration time,
every binding — including cross-feature ones — must be in place before the
routers are mounted. register_bindings walks the
subpackages of a package, imports each one's di module and calls its
register(), so the whole wiring happens up front in one order-independent call:
from fastapi_standalone_di import register_bindings
register_bindings("myapp.features") # before include_router / router discovery
A subpackage with no di module is skipped silently (a feature may declare no
bindings); a di module that exposes no callable register is surfaced with a
logging.warning instead of failing at request time. Point it at several feature
roots at once with register_bindings(pkg_a, pkg_b), nest the binding module with
module="api.di", rename the callable with attr=..., or cover nested feature
trees with recursive=True.
Each package you pass wires its own di module too, not just its
subpackages — so an entry point that needs only a couple of features can wire
exactly those instead of a whole subtree:
from fastapi_standalone_di import register_bindings
# web entry point: every feature under the root
register_bindings("myapp.features")
# daemon entry point: only these two features
register_bindings("myapp.features.config", "myapp.features.source")
Overlapping targets (e.g. a root and one of its children) each bind at most once.
Auto-wiring bindings by convention¶
When each interface has exactly one implementation that already subclasses it,
even the per-feature di module is boilerplate. auto_bindings
derives the wiring from the class hierarchy: it scans packages for interface
classes (those carrying RegistrableDependency as a direct base) and for
implementation classes, then binds each interface to the implementation that
declares it as a direct base.
from fastapi_standalone_di import Binding, RegistrableDependency, auto_bindings
class ICache(RegistrableDependency): ...
class RedisCache(ICache): # discovered as the implementation of ICache
...
bindings: list[Binding] = auto_bindings("myapp")
for binding in bindings:
verb = "kept" if binding.already_bound else "bound"
print(verb, binding.interface.__qualname__)
Pass one set of packages positionally (they may hold both interfaces and
implementations), or split the roles with interfaces=[...] and
implementations=[...]. An interface that already carries an implementation is
left untouched and reported with already_bound=True; one with no match, or an
unresolved ambiguity, raises AutoBindingError without registering anything
(resolution and registration are separate phases). Scanning is recursive by
default, and — unlike register_bindings — imports every module in the scanned
packages to inspect their classes, so call it once at bootstrap.
When several implementations match one interface, mark the one to bind with
@provides(primary=True) — it wins the tie with no extra wiring,
whether it is a class or a factory function:
from fastapi_standalone_di import RegistrableDependency, auto_bindings, provides
class ICache(RegistrableDependency): ...
@provides(primary=True)
class RedisCache(ICache): ...
class MemCache(ICache): ... # also matches, but RedisCache is primary
auto_bindings("myapp") # ICache -> RedisCache
When several implementations match, they are ranked before any conflict_solver:
- a single
@provides(primary=True)candidate wins outright (two or more primaries for one interface is anAutoBindingError); - otherwise
@provides-marked candidates beat unmarked ones — a factory function is always marked, an implementation class only when decorated — so a lone marked candidate wins over any number of bare implementation classes.
Only candidates left tied at the winning rank reach a conflict_solver. So a
@provides factory automatically wins over a plain implementation class, with no
extra wiring:
from fastapi_standalone_di import RegistrableDependency, auto_bindings, provides
class IClock(RegistrableDependency): ...
class SystemClock(IClock): ... # bare class, unmarked
@provides
def build_clock() -> IClock: # marked, so it wins
return SystemClock()
auto_bindings("myapp") # IClock -> build_clock
For a tie no rank can settle — several marked candidates, or several bare classes
— pass a conflict_solver; called once per still-ambiguous interface with the
remaining contenders, it returns the chosen candidate or None to leave the
ambiguity as an error:
from collections.abc import Callable
from typing import Any
from fastapi_standalone_di import RegistrableDependency, auto_bindings
def newest(
interface: type[RegistrableDependency], impls: list[Callable[..., Any]]
) -> Callable[..., Any] | None:
return max(impls, key=lambda impl: impl.__module__)
auto_bindings("myapp", conflict_solver=newest)
Each candidate is an implementation class or a @provides factory
function (see below), so both primary and a conflict_solver see both.
An implementation class may be decorated with singleton:
auto_bindings discovers it through the class it wraps and registers the wrapper,
so the application-lifetime cache survives instead of being bound away. Both eager
and lazy modes are wired this way.
from fastapi_standalone_di import RegistrableDependency, auto_bindings, singleton
class ICache(RegistrableDependency): ...
@singleton
class RedisCache(ICache):
def __init__(self) -> None: ...
auto_bindings("myapp") # ICache -> the singleton wrapper of RedisCache
Factory functions with @provides¶
auto_bindings matches implementation classes by their hierarchy — the
interface is a direct base. A factory function has no bases, so mark it with
provides and the interface it implements is read from its return
annotation. This wires a dependency whose construction needs a factory (an
__init__ taking raw values, a value assembled from other dependencies) without
a hand-written register() call.
from fastapi import Depends
from fastapi_standalone_di import (
RegistrableDependency,
auto_bindings,
provides,
singleton,
)
class ConfigStore: ...
def get_store() -> ConfigStore:
return ConfigStore()
class ConfigState(RegistrableDependency): ...
class _FileConfigState(ConfigState):
def __init__(self, store: ConfigStore) -> None:
self.store = store
@singleton # optional: cache it for the application lifetime
@provides
def build_config(store: ConfigStore = Depends(get_store)) -> ConfigState:
return _FileConfigState(store)
auto_bindings("myapp") # ConfigState -> build_config
The return annotation may be the interface itself or a concrete implementation of
it; the function is then matched exactly as an implementation class is — by the
returned type's direct interface bases. It may also be a generator's element type
— Iterator[X] / AsyncIterator[X] (or the Generator forms) — for a factory
with yield teardown: the yielded X is the interface it provides. Pair it with
@singleton(lazy=True) so the container owns that teardown for the application
lifetime.
from collections.abc import AsyncIterator
from fastapi_standalone_di import RegistrableDependency, provides, singleton
class RateLimiterRegistry(RegistrableDependency):
async def aclose(self) -> None: ...
@singleton(lazy=True)
@provides
async def build_limiter() -> AsyncIterator[RateLimiterRegistry]:
registry = RateLimiterRegistry()
try:
yield registry
finally:
await registry.aclose()
@provides is mandatory on a factory function (a plain function returning an
interface is left alone) and optional on a class for plain wiring (classes are
already wired by their hierarchy) — but @provides(primary=True) applies to a
class too, to win a tie (see above). Combined with @singleton, in either order,
the singleton wrapper is registered so its cache survives; used alone, the
function is rebuilt on every resolution. A @provides whose return type carries
no interface at all — Any, missing, or unrelated to RegistrableDependency — is
reported as an AutoBindingError, since the marker promises an implementation.
Sharing application state¶
AppState gives dependencies a unified handle on
application-level objects (clients, caches, …) whether they run inside a request
or standalone. In FastAPI mode it delegates to request.app.state; standalone it
falls back to a module-level singleton store.
import asyncio
from fastapi import Depends
from fastapi_standalone_di import (
AppState,
FastAPIContainer,
get_app_state,
set_app_state_value,
)
class Db: ...
def get_db(app_state: AppState = Depends(get_app_state)) -> Db | None:
db: Db | None = app_state.get("db")
return db
async def main() -> None:
set_app_state_value("db", Db()) # at startup
container = FastAPIContainer()
db = await container.get(get_db)
assert db is not None
asyncio.run(main())
Set values at startup with set_app_state_value(key, value) — this writes to the
standalone singleton so the value is available to both FastAPI and standalone
contexts. Inside a dependency, obtain the AppState by depending on
get_app_state. To back the container's state with a real application instead,
pass app_state=AppState.from_app(app).
The get_container dependency completes the picture:
register a container in app_state under "container" and any dependency can
retrieve the active container by depending on get_container.
Singletons¶
singleton turns a dependency factory into an
application-lifetime singleton: its instance is built lazily on first access,
cached in the AppState, and reused thereafter. Because the
store is the AppState, the same instance is shared across requests in ASGI
(via request.app.state) and across containers standalone (when they share an
AppState) — the cache gate lives inside the dependency both engines invoke.
import asyncio
from fastapi import Depends
from fastapi_standalone_di import FastAPIContainer, singleton
class Settings:
url = "postgres://localhost/app"
class Database:
def __init__(self, url: str) -> None:
self.url = url
@singleton(key="db")
def get_db(settings: Settings = Depends(Settings)) -> Database:
return Database(settings.url) # body runs once; result cached in app_state["db"]
async def main() -> None:
async with FastAPIContainer() as container:
first = await container.get(get_db)
second = await container.get(get_db)
assert first is second
assert first.url == "postgres://localhost/app"
asyncio.run(main())
Use it with Depends(get_db) in a route or container.get(get_db) standalone —
same instance either way. key names the AppState entry; sharing it with
set_app_state_value("db", ...) presets the singleton (the preset short-circuits
construction). Omit key for a namespaced, collision-free default.
By default (eager mode) the factory body runs once, while its Depends(...)
sub-tree is re-resolved on each access — fine for cheap sub-dependencies; make
expensive ones singletons too (it composes). Eager mode rejects generator
factories: there is no application-lifetime owner for their teardown.
For a yield singleton (or to resolve the sub-tree exactly once), pass
lazy=True. Construction is then delegated to the container reachable via
get_container, which owns the teardown and runs it at
aclose() — application shutdown:
import asyncio
from collections.abc import AsyncIterator
from fastapi_standalone_di import FastAPIContainer, singleton
class Client:
async def close(self) -> None: ...
@singleton(key="client", lazy=True)
async def get_client() -> AsyncIterator[Client]:
client = Client()
try:
yield client
finally:
await client.close() # runs at container close == app shutdown
async def main() -> None:
async with FastAPIContainer() as container:
first = await container.get(get_client)
second = await container.get(get_client)
assert first is second
# client.close() has run here
asyncio.run(main())
In ASGI, a lazy=True singleton needs a container registered in app_state to
build its value. The easiest way is the
container_lifespan helper, which installs one at
startup and closes it (running the singletons' yield teardown) at shutdown:
from fastapi import FastAPI
from fastapi_standalone_di import container_lifespan
app = FastAPI(lifespan=container_lifespan)
To compose it with your own startup/shutdown, wrap it:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
async with container_lifespan(app):
... # your own startup
yield
If you register the container yourself instead
(app.state.container = FastAPIContainer(app_state=AppState.from_app(app))),
remember to call await container.aclose() at shutdown so yield teardown runs.
A container is not required when the value is preset under the singleton's
key (app.state.<key> = value, or set_app_state_value(key, value)): the preset
short-circuits construction before any container is needed. Standalone, the
resolving container always provides itself.
Dependency scopes¶
Each dependency has a DependencyScope that decides
its lifetime and when its yield teardown runs:
CONTAINER(default) — one instance per container, torn down ataclose().SCOPED— one instance per active scope, torn down when that scope closes.
A scope is opened explicitly with async with container.scope(), and implicitly
around container.invoke(fn). Resolving a SCOPED dependency outside a scope
raises ScopeError — use a scope (or invoke) for those.
The scope is configurable globally with default_scope and per dependency with
scopes; the per-dependency map wins. default_scope also accepts a dict
mapping FastAPI's Depends(scope=...) literals ("request" / "function", plus
None for no explicit scope) to a DependencyScope.
import asyncio
from collections.abc import AsyncIterator
from fastapi import Depends
from fastapi_standalone_di import DependencyScope, FastAPIContainer
class Session:
async def close(self) -> None: ...
async def get_session() -> AsyncIterator[Session]:
session = Session()
try:
yield session
finally:
await session.close()
class Repository:
def __init__(self, session: Session = Depends(get_session)) -> None:
self.session = session
async def handler(repo: Repository = Depends(Repository)) -> Session:
return repo.session
async def main() -> None:
# The session and its repository live for one scope; anything else stays
# container-scoped (a singleton per container).
container = FastAPIContainer(
scopes={
get_session: DependencyScope.SCOPED,
Repository: DependencyScope.SCOPED,
},
)
async with container.scope() as scope:
repo = await scope.get(Repository)
assert isinstance(repo.session, Session)
# session.close() has run here, at scope exit
await container.invoke(handler) # opens a scope implicitly around the call
# the session used by handler is closed once invoke() returns
await container.aclose()
asyncio.run(main())
A CONTAINER-scoped dependency cannot depend on a SCOPED one: the container
would capture an instance torn down at scope close (a captive dependency), so the
container raises ScopeError. Make the dependent SCOPED too.
Orthogonally to the scope, FastAPI's use_cache (default True) controls
whether an instance is shared between consumers within a scope or created fresh at
each injection point. A yield dependency's resources stay open until the scope
that owns it closes — so a fresh (use_cache=False) generator at CONTAINER
scope is held until aclose(); put transient resources in a SCOPED scope sized
as one unit of work.
Concurrency¶
Resolution is concurrency-safe: several get/resolve/invoke calls may run
concurrently on the same container (e.g. under asyncio.gather). Concurrent
resolutions of the same shared dependency are serialised, so a cache miss still
yields a single instance registered once on its exit stack — no duplicate
instance and no double teardown. Independent dependencies are not serialised
against each other; the lock only guards same-key construction, not parallel
throughput.