Backend / Web frameworks / FastAPI / 03_dependencies_and_injection.md

Dependencies and injection

Updated 7 interview angles 5 min read source
On this page9
  1. A dependency is any callable
  2. The graph is resolved once per request
  3. Classes, when the dependency needs configuration
  4. Router-level, for anything that should be default
  5. Sync dependencies go to the threadpool
  6. Why this is worth doing: the test seam
  7. Do you need a DI container?
  8. Related
  9. Interview angle

Dependencies and injection

Depends is FastAPI’s defining feature. It resolves a graph per request, caches within it, guarantees teardown, and — the part that matters most — it is the seam that makes the app testable.

A dependency is any callable

python
async def get_session() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as s:
        try:
            yield s
            await s.commit()
        except Exception:
            await s.rollback()
            raise

@app.post("/orders")
async def create(
    body: OrderIn,
    session: Annotated[AsyncSession, Depends(get_session)],
): ...

Everything before yield is setup, everything after is teardown, and the teardown runs whether the handler returned or raised. The handler never opens a session, never closes one, and never has to remember to roll back.

Annotated[T, Depends(f)] is the current form. The older session: AsyncSession = Depends(get_session) still works and puts a runtime value in a default slot, which breaks reuse of the signature and confuses type checkers.

The graph is resolved once per request

python
async def current_user(
    s: Annotated[AsyncSession, Depends(get_session)],
    token: Annotated[str, Depends(oauth2)],
) -> User:
    return await s.get(User, decode(token).sub)

@app.get("/me")
async def me(
    user: Annotated[User, Depends(current_user)],
    # same session
    s: Annotated[AsyncSession, Depends(get_session)],
): ...

Dependencies compose — one can request another — and a dependency needed twice in a request is called once, its result reused. That caching is why get_session above yields the same session to the handler and to current_user, which is what makes a single transaction per request work.

python
# opt out when you want a fresh call
Depends(make_nonce, use_cache=False)

Classes, when the dependency needs configuration

python
class RequireScope:
    def __init__(self, scope: str):
        self.scope = scope

    def __call__(
        self, user: Annotated[User, Depends(current_user)]
    ) -> User:
        if self.scope not in user.scopes:
            raise HTTPException(403, "missing scope")
        return user

WRITE = RequireScope("orders:write")

@app.delete("/orders/{id}")
async def delete(user: Annotated[User, Depends(WRITE)]): ...

The constructor takes the configuration, __call__ takes the injected values. That is how one check serves many scopes without forty near-identical functions.

Router-level, for anything that should be default

python
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])

A dependency listed here runs for every route on the router and its return value is discarded — it exists to raise. That makes a whole area secure by default rather than by remembering, which is the difference between a policy and a hope.

Level Use for
FastAPI(dependencies=...) request id, global rate limit
APIRouter(dependencies=...) auth for a whole area
Route signature anything the handler needs a value from

Sync dependencies go to the threadpool

A dependency follows the same rule as a handler: def runs in the threadpool, async def runs on the loop. So a synchronous dependency doing blocking I/O on every request consumes a thread every request, and the pool is bounded — see FastAPI and the event loop.

Gotcha: an exception raised after yield in a dependency happens once the response has already started. FastAPI cannot turn it into a 500, so it surfaces as a server error in the logs with a partially-sent response. Keep teardown boring: close things, do not do work that can fail.

Why this is worth doing: the test seam

python
overrides = app.dependency_overrides
overrides[get_session] = lambda: test_session
overrides[billing_client] = lambda: FakeBilling()

def test_creates_order(client):
    r = client.post("/orders", json=payload)
    assert r.status_code == 201

The whole real request path still runs — routing, validation, middleware, serialisation, the response model — and only the leaves are swapped. Compare with monkeypatching a module attribute, which depends on where the name was imported and breaks when someone moves it.

Two rules: override by the function object, not by name, since the key is identity; and clear between tests, or one test’s fake leaks into the next.

python
@pytest.fixture(autouse=True)
def _reset_overrides():
    yield
    app.dependency_overrides.clear()

Do you need a DI container?

Almost never. Depends covers the request-scoped graph, and a composition root covers the rest:

python
# main.py — the only module that names concrete classes.
def build(settings: Settings) -> UserService:
    engine = create_async_engine(settings.database_url)
    return UserService(repo=SqlUserRepository(engine), mailer=SesMailer())

A container earns its place when the same graph is needed outside HTTP — Celery tasks, a CLI, a consumer — because Depends only resolves during a request. Even then, plain constructor arguments wired in one place usually beat a framework. See Dependency Injection.

The signal is a test. If swapping a collaborator needs monkeypatch, that collaborator is being constructed rather than injected.

Interview angle 7

  • “What does Depends actually do?” - resolves a dependency graph per request and injects the results. Dependencies can request other dependencies, and one needed twice in a request is called once with its result reused — which is how a single session reaches the handler and everything below it.
  • “What does a yield dependency give you?” - setup before the handler and guaranteed teardown afterwards, so a session is opened, committed or rolled back, and closed without the handler managing any of it.
  • “How do you apply a dependency to a whole area?” - APIRouter(dependencies=[Depends(require_admin)]). It runs on every route and its return value is discarded; it exists to raise. Secure by default rather than by remembering.
  • “How do you make a dependency take an argument?” - a class with __call__: the constructor takes configuration, __call__ takes the injected values. One permission check then serves many scopes.
  • “Why is dependency_overrides better than monkeypatching?” - it swaps by function identity, so it does not care where the symbol was imported, and the entire real request path still runs. Clear the overrides between tests or a fake leaks forward.
  • “Do you need a DI container with FastAPI?” - rarely. Depends covers the request scope and a composition root covers construction. A container earns its place when the same graph is needed outside a request — a Celery task, a CLI, a consumer.
  • “What’s the trap in a yield dependency?” - raising after the yield. The response has already started, so FastAPI cannot convert it into a 500; you get a logged server error and a partial response. Keep teardown to closing things.