Routers, project layout and API versioning
Split a growing application into modules with APIRouter, share prefixes and dependencies, and introduce /api/v1 without breaking existing clients.
APIRouter
# app/routers/items.py
from fastapi import APIRouter, Depends
from ..deps import get_current_user
from ..schemas import Item, ItemOut
router = APIRouter(
prefix="/items",
tags=["items"],
dependencies=[Depends(get_current_user)],
responses={404: {"description": "Not found"}},
)
@router.get("", response_model=list[ItemOut])
def list_items():
return []
@router.get("/{item_id}", response_model=ItemOut)
def get_item(item_id: int):
return {"id": item_id, "name": "widget", "price": 9.99}# app/main.py
from fastapi import FastAPI
from .routers import items, users
app = FastAPI(title="Shop API", version="1.0.0")
app.include_router(items.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")- The effective path is the router prefix plus the include prefix plus the route path.
- Router-level
dependenciesrun for every route in that router, which is how you require authentication for a whole area. - An empty route path (
"") keeps the collection endpoint at the prefix without a trailing slash.
A layout that survives growth
app/
main.py # creates FastAPI, includes routers, adds middleware
config.py # Settings via pydantic-settings
db.py # engine, session factory, get_db dependency
deps.py # shared dependencies: current user, pagination
models/ # SQLAlchemy tables
schemas/ # Pydantic request/response models
routers/
items.py
users.py
services/ # business logic, no FastAPI imports
tests/
conftest.py
test_items.pyThe rule that keeps this maintainable: routers translate HTTP to function calls, services hold the logic, and services do not import FastAPI. That makes the important code testable without a client.
Versioning
v1 = APIRouter(prefix="/api/v1")
v2 = APIRouter(prefix="/api/v2")
@v1.get("/items")
def items_v1():
return [{"id": 1, "title": "widget"}] # legacy field name
@v2.get("/items")
def items_v2():
return [{"id": 1, "name": "widget"}] # renamed in v2
app.include_router(v1)
app.include_router(v2)Prefix versioning is the least disruptive option: old clients keep working, both versions appear in /docs as separate tag groups, and you retire v1 when the traffic graph reaches zero.
⚠️
Do not include the same router twice with the same prefix — FastAPI accepts it and the first match wins, which produces routes that exist in OpenAPI but are unreachable. Check
app.routes when a path behaves unexpectedly.FAQ
Trailing slash or not?
FastAPI redirects
/items/ to /items by default, but a 307 redirect loses the body on some clients. Define paths explicitly and be consistent, especially for POST endpoints behind a proxy.Should I put validation logic in the router?
Keep request shape in Pydantic schemas and business rules in services. A router that grows past a couple of dozen lines of logic is usually a service waiting to be extracted.
Related
Your first FastAPI app Deployment: uvicorn workers, Docker and reverse proxies
Last refreshed 2026-09-18.