Staff Prep 17: FastAPI Internals — Routing, DI, and Performance Tuning
ArchitectureStaff

Staff Prep 17: FastAPI Internals — Routing, DI, and Performance Tuning

April 4, 20269 min readPART 15 / 18

Back to Part 16: asyncio Deep Dive. FastAPI is a relatively thin layer on top of Starlette. It adds automatic OpenAPI generation, Pydantic validation, and a powerful dependency injection system. Understanding what each layer does — and what each costs — helps you make the right performance trade-offs and debug issues that span multiple layers.

FastAPI's layered architecture

Client HTTP Request
       │
       ▼
  Uvicorn (ASGI server) — TCP, HTTP parsing
       │
       ▼
  Starlette (routing, middleware, request/response)
       │
       ▼
  FastAPI (Pydantic validation, DI, OpenAPI generation)
       │
       ▼
  Your route handler function

FastAPI adds overhead on top of bare Starlette. For most workloads, this overhead is negligible (microseconds). For ultra-high-throughput endpoints (>100k req/s), the validation cost matters.

Router organisation at scale

python
from fastapi import FastAPI, APIRouter, Depends

# api/routers/users.py
router = APIRouter(
    prefix="/users",
    tags=["users"],
    dependencies=[Depends(verify_token)],  # applied to all routes in this router
)

@router.get("/{user_id}")
async def get_user(user_id: int, db=Depends(get_db)):
    return await db.get(User, user_id)

@router.post("/", status_code=201)
async def create_user(user: UserCreate, db=Depends(get_db)):
    return await db.create(User, user.dict())

# api/routers/orders.py
orders_router = APIRouter(prefix="/orders", tags=["orders"])

# main.py
app = FastAPI()
app.include_router(users.router, prefix="/v1")
app.include_router(orders.orders_router, prefix="/v1")

# All routes: /v1/users/{id}, /v1/orders, etc.

Dependency injection: scoping and caching

python
from fastapi import Depends

# Default: cached per request — same instance reused
async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

# use_cache=False: new instance per injection point
# (rarely needed, but useful for testing isolation)
async def get_uncached_db(db: AsyncSession = Depends(get_db, use_cache=False)):
    return db

# Dependency with its own state: shared across requests
# Pattern: singleton dependency via module-level state
class DatabaseService:
    def __init__(self):
        self.pool = None

    async def startup(self):
        self.pool = await asyncpg.create_pool("postgresql://...")

db_service = DatabaseService()

@app.on_event("startup")
async def startup():
    await db_service.startup()

def get_db_service() -> DatabaseService:
    return db_service  # returns the same singleton

@app.get("/health")
async def health(db: DatabaseService = Depends(get_db_service)):
    await db.pool.fetchval("SELECT 1")
    return {"ok": True}

Middleware vs dependencies: choosing the right layer

python
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
import uuid
import time

# Use Middleware for:
# - Cross-cutting concerns (logging, timing, CORS, auth headers)
# - Things that apply to ALL routes including static files
# - Request/response transformation at the bytes level

class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        request_id = str(uuid.uuid4())
        request.state.request_id = request_id
        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id
        return response

# Use Dependencies for:
# - Business logic setup (DB sessions, current user, permissions)
# - Things that should only run for specific routes or routers
# - Things that need to be testable and mockable in isolation

async def get_current_user(token: str = Depends(get_token), db=Depends(get_db)):
    # Only runs for routes that declare this dependency
    # Can be easily mocked in tests
    return await verify_and_fetch_user(token, db)

# Key difference: middleware cannot access route handler return values easily
# Dependencies can see the full request context but not raw bytes

Response model: validation and performance

python
from pydantic import BaseModel
from typing import Optional

class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    # NOTE: password, internal_flags NOT in response model
    # FastAPI filters these out automatically

@app.get("/users/{id}", response_model=UserResponse)
async def get_user(id: int, db=Depends(get_db)):
    user = await db.get(User, id)
    return user  # FastAPI serialises through UserResponse, strips excluded fields

# Performance: response_model validation adds overhead
# For hot paths returning large datasets:
@app.get("/users/bulk", response_model=None)  # skip validation
async def get_users_bulk(db=Depends(get_db)):
    users = await db.execute("SELECT id, name, email FROM users LIMIT 1000")
    from fastapi.responses import ORJSONResponse
    return ORJSONResponse(content=[dict(u) for u in users.fetchall()])
    # ORJSONResponse is 2-3x faster than default JSONResponse for large payloads

Streaming responses for large data

python
from fastapi.responses import StreamingResponse
import asyncio

# Stream large CSV export without loading everything into memory
async def generate_csv_stream(query_params: dict):
    yield "id,name,email
"  # header

    offset = 0
    batch_size = 1000

    while True:
        rows = await db.execute(
            "SELECT id, name, email FROM users LIMIT $1 OFFSET $2",
            batch_size, offset
        )
        batch = rows.fetchall()
        if not batch:
            break

        for row in batch:
            yield f"{row['id']},{row['name']},{row['email']}
"

        offset += batch_size
        await asyncio.sleep(0)  # yield to event loop

@app.get("/users/export.csv")
async def export_users():
    return StreamingResponse(
        generate_csv_stream({}),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=users.csv"}
    )

Custom exception handlers and error shapes

python
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=422,
        content={
            "error": "validation_failed",
            "fields": [
                {"path": " -> ".join(str(x) for x in err["loc"]),
                 "message": err["msg"]}
                for err in exc.errors()
            ]
        }
    )

class NotFoundError(Exception):
    def __init__(self, resource: str, id: int):
        self.resource = resource
        self.id = id

@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
    return JSONResponse(
        status_code=404,
        content={"error": f"{exc.resource} with id {exc.id} not found"}
    )

@app.get("/orders/{id}")
async def get_order(id: int, db=Depends(get_db)):
    order = await db.get(Order, id)
    if not order:
        raise NotFoundError("Order", id)
    return order

Quiz: test your understanding

Before moving on, answer these in your head (or out loud):

  1. What is the difference between adding auth via middleware vs a dependency? Give a scenario where each is the better choice.
  2. Two routes both declare Depends(get_db) in the same request handler chain. How many DB sessions are created? What if you use use_cache=False?
  3. You need to return a 500MB CSV file from a FastAPI endpoint. What goes wrong if you return content as a single string? How do you fix it?
  4. What does response_model=UserResponse do in terms of security? What happens if your ORM model has a hashed_password field not in the response model?
  5. Where in the middleware stack does Pydantic request validation happen? Before middleware or after?

Next up — Part 18: Task Queues & Celery. Broker vs backend, worker architecture, prefetch, and idempotency patterns.

← PREV
Staff Prep 16: asyncio Deep Dive — Event Loop, Tasks & Common Pitfalls
← All Architecture Posts