A route starts innocently: validate a request and insert one row. Soon it checks permissions, calls an external provider, opens a transaction, sends analytics, and formats five failure cases. Tests must then construct HTTP requests to exercise ordinary business rules.
I treat a FastAPI route as an adapter. It should translate HTTP into one application call.
@router.post('/templates', status_code=201)
async def create_template(
body: CreateTemplateRequest,
user_id: CurrentUserId,
pool: DbPool,
) -> TemplateResponse:
return await template_service.create(pool, user_id, body)
The route declares its contract, obtains authenticated and infrastructure dependencies, and delegates the use case.
Put HTTP concerns in the router#
The router may own:
- paths, methods, and status codes;
- query and body schemas;
- headers and streaming response construction;
- mapping an application result to the public response;
- dependencies that establish identity or transport context.
It should not own SQL, multi-step transactions, or decisions such as whether a workout can be edited after completion.
Reuse typed dependencies#
FastAPI's dependency injection becomes clearer when common requirements have names:
DbPool = Annotated[asyncpg.Pool, Depends(get_db_pool)]
CurrentUserId = Annotated[str, Depends(get_current_user)]
AppSettings = Annotated[Settings, Depends(get_settings)]
This avoids repeating Depends(...) declarations and makes route signatures read like requirements.
Dependencies are best for request-scoped technical capabilities: authentication, database access, settings, and permission gates. Hiding an entire business workflow inside a dependency makes control flow difficult to follow.
Let the service describe the use case#
async def create(
pool: asyncpg.Pool,
user_id: str,
body: CreateTemplateRequest,
) -> TemplateResponse:
await ensure_template_limit(pool, user_id)
row = await repository.insert_template(pool, user_id, body)
return TemplateResponse.model_validate(row)
Now the service can be tested without an HTTP request, while the route has a small contract test for validation, authentication, and serialization.
Thin does not mean empty. Streaming endpoints, conditional caching, and file uploads naturally contain more transport logic. The test is whether the code answers an HTTP question or a product question. HTTP belongs in the route; product behavior belongs behind it.