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 yield dependencies 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=False on one of them.
  • Mixing sync and async providers. A plain def dependency runs in a thread pool; if it blocks for a long time you can exhaust that pool. Make I/O-bound providers async.
  • 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)