Best Practices for FastAPI Dependency Injection
Key takeaways:
- Prefer
Annotated[T, Depends(...)]aliases over the legacy default-argument form for type-checker accuracy and reuse. - Provide resources through
yielddependencies so cleanup always runs. - Keep pools at application scope and sessions at request scope.
- Break circular imports by wiring in the factory or importing inside functions.
- Make every external dependency overridable so tests never touch real infrastructure.
This guide turns the concepts in Dependency Injection Strategies into concrete habits. Read that page first if you want the mechanics behind the resolution graph.
The Problem This Solves
Dependency injection done casually still works, but it accumulates friction: handler signatures fill with Depends(...) noise, sessions leak because they were opened without cleanup, and a stray module-level import deadlocks startup. The practices below keep a large injected graph readable, leak-free, and testable.
Prerequisites
- Python 3.11+ for
Annotated. - An async stack (SQLAlchemy async,
httpx.AsyncClient) to make the scoping rules concrete.
Step-by-Step Best Practices
1. Alias dependencies with Annotated
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
# Define once, reuse everywhere — handler signatures stay clean and typed.
SessionDep = Annotated[AsyncSession, Depends(get_db_session)]
CurrentUser = Annotated[dict, Depends(current_user)]
async def get_order(order_id: int, db: SessionDep, user: CurrentUser) -> dict:
return await fetch_order(db, order_id, owner=user["sub"])
2. Provide resources with yield
from collections.abc import AsyncGenerator
async def get_http_client() -> AsyncGenerator["httpx.AsyncClient", None]:
# One client per request; closed deterministically after the response.
async with httpx.AsyncClient(timeout=5.0) as client:
yield client
3. Scope pools and sessions separately
# Application scope: created once in lifespan, shared across requests.
app.state.session_factory = async_sessionmaker(engine, expire_on_commit=False)
# Request scope: a fresh session per request, drawn from the shared factory.
async def get_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
async with request.app.state.session_factory() as session:
yield session
4. Break circular imports
# Bad: router imports service, service imports router → stall at import time.
# Good: depend on a protocol and bind the concrete class in the factory.
from typing import Protocol
class OrderRepo(Protocol):
async def get(self, order_id: int) -> dict | None: ...
def get_order_repo(request: Request) -> OrderRepo:
return request.app.state.order_repo # Bound in create_app(), not imported here.
5. Override in tests
def test_get_order(client, app):
app.dependency_overrides[get_db_session] = fake_session
app.dependency_overrides[current_user] = lambda: {"sub": "u-1"}
resp = client.get("/orders/1")
assert resp.status_code == 200
app.dependency_overrides.clear()
Edge Cases and Gotchas
- Per-request cache surprises. If a route needs two distinct instances of the same provider, set
use_cache=Falseon one of them. - Mixing sync and async providers. A plain
defdependency runs in a thread pool; if it blocks for a long time you can exhaust that pool. Make I/O-bound providersasync. - Over-broad router dependencies. A dependency on a router applies to every route, including health checks; mount auth gates on the right router only.
Verification
Assert that a missing override surfaces, proving the route really depends on what you think:
def test_route_requires_session(client, app):
# With no override and no real DB configured, the route must fail loudly,
# confirming the dependency is actually wired into the handler.
app.dependency_overrides.clear()
resp = client.get("/orders/1")
assert resp.status_code in (500, 503)
Related Reading
- Up to the topic: Dependency Injection Strategies.
- Related patterns: Application Factory Patterns for where wiring lives, and Async Database Sessions for session scoping under load.