The FastAPI Depends() pattern I add to every project on day one
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:
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:
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:
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:
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:
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:
@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:
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:
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.