The FastAPI Depends() pattern I add to every project on day one
← Back
April 2, 2026Python7 min read

The FastAPI Depends() pattern I add to every project on day one

Published April 2, 20267 min read

I discovered FastAPI's Depends() in week one. I figured out how to compose dependencies in week eight. The gap between those two moments cost me a lot of duplicated validation logic and a messy auth layer that I had to refactor when the project grew. Here is what I wish I had set up on day one.

What most FastAPI tutorials show you

The basic dependency pattern is in every tutorial: inject a DB session, inject the current user. Most examples look like this:

python
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
from .database import SessionLocal

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/items")
def read_items(db: Session = Depends(get_db)):
    return db.query(Item).all()

That is correct, but incomplete. The real power of Depends() comes from three patterns that tutorials rarely show together: lru_cache for settings, dependency composition, and async dependencies with proper cleanup.

Pattern 1: Settings with lru_cache

Reading environment variables on every request is wasteful. The idiomatic FastAPI pattern uses Pydantic's BaseSettings with lru_cache:

python
from functools import lru_cache
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    database_url: str
    secret_key: str
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30
    redis_url: str = "redis://localhost:6379"

    class Config:
        env_file = ".env"


@lru_cache
def get_settings() -> Settings:
    return Settings()

Now inject settings anywhere without re-parsing the environment:

python
from fastapi import Depends
from .config import Settings, get_settings


@app.get("/info")
def app_info(settings: Settings = Depends(get_settings)):
    return {"algorithm": settings.algorithm}

The @lru_cache ensures the Settings object is created once per process. FastAPI's test override pattern (app.dependency_overrides) also works cleanly with this — swap in a test settings object without touching environment variables.

Pattern 2: Async DB sessions

For async FastAPI with SQLAlchemy 2.0, the session dependency changes shape:

python
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from .config import get_settings

settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)


async def get_db() -> AsyncSession:
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

Using async with (context manager) instead of manual try/finally ensures the session is always closed even if the commit fails. The expire_on_commit=False prevents SQLAlchemy from expiring objects after commit, which causes lazy-load errors in async contexts.

Pattern 3: Composing dependencies for auth

This is the pattern that took me too long to discover. Dependencies can depend on other dependencies:

python
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from .config import Settings, get_settings
from .db import get_db
from .models import User

security = HTTPBearer()


async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Security(security),
    db: AsyncSession = Depends(get_db),
    settings: Settings = Depends(get_settings),
) -> User:
    token = credentials.credentials
    try:
        payload = jwt.decode(
            token,
            settings.secret_key,
            algorithms=[settings.algorithm],
        )
        user_id: str = payload.get("sub")
        if not user_id:
            raise HTTPException(status_code=401, detail="Invalid token")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(status_code=401, detail="User not found")

    return user


async def get_active_user(user: User = Depends(get_current_user)) -> User:
    """Extends get_current_user — adds active check."""
    if not user.is_active:
        raise HTTPException(status_code=403, detail="Account deactivated")
    return user


async def get_admin_user(user: User = Depends(get_active_user)) -> User:
    """Extends get_active_user — adds admin check."""
    if not user.is_admin:
        raise HTTPException(status_code=403, detail="Admin access required")
    return user

Notice the chain: get_admin_user depends on get_active_user, which depends on get_current_user, which depends on get_db and get_settings. FastAPI resolves the entire graph, deduplicates shared dependencies (one DB session per request even if three deps request it), and injects everything in the right order.

Using these is clean:

python
@router.get("/me")
async def get_my_profile(current_user: User = Depends(get_active_user)):
    return current_user

@router.delete("/users/{user_id}")
async def delete_user(
    user_id: str,
    db: AsyncSession = Depends(get_db),
    _: User = Depends(get_admin_user),  # _ = only care about auth side effect
):
    ...

Pattern 4: Rate limiting as a dependency

Dependencies do not have to return anything useful — they can be used purely for their side effects:

python
import redis.asyncio as redis
from fastapi import Request

async def rate_limit(
    request: Request,
    settings: Settings = Depends(get_settings),
) -> None:
    client_ip = request.client.host
    r = redis.from_url(settings.redis_url)
    key = f"rate_limit:{client_ip}"

    count = await r.incr(key)
    if count == 1:
        await r.expire(key, 60)  # 1 minute window

    if count > 100:
        raise HTTPException(status_code=429, detail="Too many requests")

    await r.aclose()


@router.post("/expensive-endpoint")
async def expensive(
    body: RequestBody,
    _: None = Depends(rate_limit),
):
    ...

Testing with dependency_overrides

The cleanest thing about this pattern: testing is trivial. Swap any dependency in tests without touching production code:

python
from httpx import AsyncClient
from app.main import app
from app.dependencies import get_current_user, get_db
from tests.factories import UserFactory, fake_db_session

async def test_get_profile():
    fake_user = UserFactory.build()

    app.dependency_overrides[get_current_user] = lambda: fake_user
    app.dependency_overrides[get_db] = fake_db_session

    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get("/me")

    assert response.status_code == 200
    assert response.json()["id"] == str(fake_user.id)

    app.dependency_overrides.clear()

The single file I copy on day one

I now keep a dependencies.py template in a gist: get_settings, get_db, get_current_user, get_active_user, get_admin_user, and rate_limit. Dropping that file into a new project on day one means every endpoint has production-quality auth, DB access, and rate limiting from the first commit — not retrofitted three weeks in when the shortcuts start to hurt.

The dependency composition pattern is FastAPI's most powerful feature. Most developers find it in week eight. You now have it in week zero.

Share this
← All Posts7 min read