Let’s Start With a Real Problem
Your API started small — a handful of user endpoints in one file. Then you added products. Then orders. Then an admin panel. Now main.py is 800 lines long, and every time two developers on your team touch it at the same time, you get a painful merge conflict. Worse, nobody can remember which endpoints require an API key and which don’t, because that logic is copy-pasted into 30 different functions.
You’ve actually already been using the fix throughout this entire series — APIRouter — one resource, one file, one router. This chapter takes that pattern and makes it industrial-strength: versioned APIs, shared router-level configuration, and a clean aggregation pattern used by real, large FastAPI codebases.
What You’ll Learn
- Why splitting routes across files matters as a project grows
- How to cleanly aggregate multiple routers into one, versioned API surface (
/api/v1/...) - How to apply shared configuration — prefixes, tags, dependencies, and documented responses — at the router level, instead of repeating it on every single route
- How router-level
dependencies=[...]can protect an entire group of routes in one line - Theory + hands-on assignment questions to lock in what you’ve learned
Theory: Why APIRouter Exists
APIRouter is essentially a “mini FastAPI app” — it supports the same @router.get(), @router.post(), and other decorators, but it doesn’t run on its own. You build routers in separate files, then plug them into your main FastAPI() app using app.include_router(...).
The real power shows up once you realize routers can be nested. A router can include other routers, which means you can build an organizational hierarchy like:
app → api_router (prefix: /api/v1) → users_router (prefix: /users)
→ products_router (prefix: /products)
This lets large teams version their entire API (/api/v1/... today, /api/v2/... tomorrow) without rewriting every single route — you just point a different router at the new prefix.
Program 1: Aggregating Multiple Routers into a Versioned API
Program Objective
Build a small, standalone FastAPI project with two resources — Users and Products — each in their own router file, combined into a single versioned API surface at /api/v1/... using a central aggregator router, keeping main.py almost empty.
Step-by-Step Implementation
Step 1: Create the project
uv init router-aggregation-demo
cd router-aggregation-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
Step 2: Create the project structure
Notice the new api/v1/ layer — this is the key structural change in this chapter.
mkdir -p app/core app/db app/models app/schemas app/services app/api/v1/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/api/__init__.py app/api/v1/__init__.py app/api/v1/routers/__init__.py
router-aggregation-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ └── config.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── product.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── product.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── product_service.py
│ └── api/
│ ├── __init__.py
│ └── v1/
│ ├── __init__.py
│ ├── api.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ └── products.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Router Aggregation Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
settings = Settings()
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
app/models/product.py
from sqlalchemy import Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
price: Mapped[float] = mapped_column(Numeric(10, 2))
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
model_config = ConfigDict(from_attributes=True)
app/schemas/product.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
class ProductCreate(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=100)]
price: Annotated[float, Field(gt=0)]
class ProductOut(BaseModel):
id: int
name: str
price: float
model_config = ConfigDict(from_attributes=True)
app/services/user_service.py
from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(username=user_in.username, email=user_in.email)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def list_users(db: Session) -> list[User]:
return db.query(User).all()
app/services/product_service.py
from sqlalchemy.orm import Session
from app.models.product import Product
from app.schemas.product import ProductCreate
def create_product(db: Session, product_in: ProductCreate) -> Product:
new_product = Product(name=product_in.name, price=product_in.price)
db.add(new_product)
db.commit()
db.refresh(new_product)
return new_product
def list_products(db: Session) -> list[Product]:
return db.query(Product).all()
app/api/v1/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, list_users
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
@router.get("/", response_model=list[UserOut])
def get_all_users(db: Session = Depends(get_db)):
return list_users(db)
app/api/v1/routers/products.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.product import ProductCreate, ProductOut
from app.services.product_service import create_product, list_products
router = APIRouter(prefix="/products", tags=["Products"])
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_new_product(product_in: ProductCreate, db: Session = Depends(get_db)):
return create_product(db, product_in)
@router.get("/", response_model=list[ProductOut])
def get_all_products(db: Session = Depends(get_db)):
return list_products(db)
app/api/v1/api.py
from fastapi import APIRouter
from app.api.v1.routers import products, users
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(users.router)
api_router.include_router(products.router)
app/main.py
from fastapi import FastAPI
from app.api.v1.api import api_router
from app.core.config import settings
from app.db.database import Base, engine
from app.models import product, user # noqa: F401
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(api_router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
api/v1/routers/users.pyandproducts.pyeach define their ownAPIRouterwith their ownprefix(/users,/products) andtags— exactly the pattern you’ve used since Chapter 5. Nothing new here individually; the new idea is how they get combined.api/v1/api.pyintroduces the aggregator pattern: a singleapi_routerwithprefix="/api/v1", which then callsinclude_router()on every individual resource router. This means the final URLs become/api/v1/users/and/api/v1/products/— the/api/v1prefix is applied automatically to everything nested inside, without repeating it in every router file.main.pynow only needs toinclude_router(api_router)once. If you add a tenth resource next month,main.pydoesn’t change at all — you only touchapi/v1/api.pyto register the new router, and create the new router file itself.- Why this matters for versioning: imagine you need a
/api/v2/userswith breaking changes down the line. You’d create a parallelapp/api/v2/folder with its ownapi.py, and include bothapi_router_v1andapi_router_v2inmain.py. Old clients keep using/api/v1/...uninterrupted while new clients adopt/api/v2/...— a real, common pattern in production APIs.
Execution & Testing
uv run fastapi dev app/main.py
Open http://127.0.0.1:8000/docs:
- Confirm every endpoint now lives under
/api/v1/...— e.g.POST /api/v1/users/andGET /api/v1/products/. - Confirm the Swagger UI still groups everything neatly under Users and Products tags, even though the URLs are nested under a shared prefix.
- Test creating a user and a product, and listing both, to confirm both routers work correctly through the aggregator.
Program 2: Router-Level Dependencies and Shared Configuration
Program Objective
Build a small, standalone FastAPI project with an “admin” router where every single route requires a simple API key header — enforced once, at the router level, instead of being repeated on every endpoint.
Step-by-Step Implementation
Step 1: Create the project
uv init router-dependencies-demo
cd router-dependencies-demo
uv add fastapi --extra standard
Step 2: Create the project structure
mkdir -p app/core app/api/v1/routers
touch app/__init__.py app/core/__init__.py app/api/__init__.py app/api/v1/__init__.py app/api/v1/routers/__init__.py
router-dependencies-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ └── security.py
│ └── api/
│ ├── __init__.py
│ └── v1/
│ ├── __init__.py
│ ├── api.py
│ └── routers/
│ ├── __init__.py
│ └── admin.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/security.py
from fastapi import Header, HTTPException, status
# NOTE: This is a simplified placeholder to demonstrate router-level dependencies.
# Real authentication (OAuth2 + JWT) is covered in Chapters 14-16.
FAKE_ADMIN_API_KEY = "super-secret-admin-key"
def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != FAKE_ADMIN_API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key.",
)
app/api/v1/routers/admin.py
from fastapi import APIRouter, Depends, status
from app.core.security import verify_api_key
router = APIRouter(
prefix="/admin",
tags=["Admin"],
dependencies=[Depends(verify_api_key)],
responses={401: {"description": "Missing or invalid API key"}},
)
@router.get("/stats")
def get_admin_stats():
return {"total_users": 128, "total_products": 42}
@router.delete("/cache", status_code=status.HTTP_204_NO_CONTENT)
def clear_cache():
# Pretend this clears an application cache.
return None
app/api/v1/api.py
from fastapi import APIRouter
from app.api.v1.routers import admin
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(admin.router)
app/main.py
from fastapi import FastAPI
from app.api.v1.api import api_router
app = FastAPI(title="Router Dependencies Demo", version="1.0.0")
app.include_router(api_router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": "Welcome to Router Dependencies Demo"}
Detailed Code Explanation
verify_api_keyis a plain FastAPI dependency function — it reads a required header calledX-API-Key(FastAPI automatically mapsx_api_keyto theX-API-Keyheader) and raises401 Unauthorizedif it doesn’t match. This is a deliberately simplified stand-in for real authentication, which we’ll build properly with OAuth2 and JWT starting in Chapter 14.- The key concept is in
admin.py:APIRouter(..., dependencies=[Depends(verify_api_key)]). Adding a dependency at the router level means it runs automatically for every single route defined in that router —GET /admin/stats,DELETE /admin/cache, and any future admin route you add — without writingDepends(verify_api_key)on each one individually. responses={401: {"description": "..."}}at the router level also documents that401response for every route in this router in Swagger UI’s documentation, again without repeating it per-route. This is purely for documentation — it doesn’t change runtime behavior, but it makes your API’s contract clearer to anyone reading/docs.- Notice the actual route functions (
get_admin_stats,clear_cache) contain zero security code — they don’t know or care that a check happened before they ran. That separation is exactly the same principle we used for exception handling in Chapter 7: keep cross-cutting concerns (auth, error formatting) out of your individual business logic.
Execution & Testing
uv run fastapi dev app/main.py
- Open
http://127.0.0.1:8000/docsand try callingGET /api/v1/admin/statsdirectly — it should fail with401, since Swagger UI doesn’t send the header automatically. - In Swagger UI, click “Try it out”, then manually add a header: click into the Parameters section (or use the “Authorize”-style header input if shown), setting
X-API-Key: super-secret-admin-key, then execute. Confirm you now get a200with the stats. - Try
DELETE /api/v1/admin/cachethe same way, first without the header (expect401), then with it (expect204).
Tips & Instructions for Developers
- ✅ Give every router its own file, grouped by resource (
users.py,products.py,orders.py) — this is the single biggest thing that keeps a growing FastAPI project navigable. - ✅ Use an aggregator file (
api/v1/api.py) to combine routers — it keepsmain.pyalmost untouched as your project grows, which means fewer merge conflicts across a team. - ✅ Apply shared concerns — auth checks, common documented responses, shared prefixes — at the router level using
dependencies=[...]andresponses={...}, rather than repeating them on every route. - ✅ Plan for versioning early (
/api/v1/...) even if you don’t needv2yet — retrofitting versioning onto an unversioned API later is painful for existing clients. - ✅ Keep router-level dependencies focused on cross-cutting concerns (auth, rate limiting, common validation) — business logic still belongs in your service layer.
- ❌ Don’t put actual business logic inside a router-level dependency — its job is to gate access or prepare shared context, not to do the work of the endpoint.
- ❌ Don’t scatter related routes across multiple files “for convenience” — one resource, one router file, keeps things predictable for the next developer (possibly future you).
Official FastAPI Documentation References
- Bigger Applications — Multiple Files
- APIRouter Class Reference
- Dependencies in Path Operation Decorators
- Path Operation Configuration
- Header Parameters
Theory Questions
- What problem does the
api/v1/api.pyaggregator pattern solve as a project grows? - Why does applying a
prefixat the aggregator level (/api/v1) avoid repeating it in every individual router? - What’s the practical benefit of putting an auth check in
dependencies=[...]at the router level instead of inside each route function? - Why is API versioning (
/api/v1/...) something worth planning for early, rather than adding later? - What’s the difference between what
dependencies=[...]does at runtime versus whatresponses={...}does?
Practical Assignment
- Add a third resource: Starting from Program 1’s project, add an
Ordersresource (its own model, schema, service, and router file) and register it inapi/v1/api.py, without touchingmain.pyat all. - Protect an existing router: Starting from Program 1’s project, apply the
verify_api_keydependency (from Program 2) to theProductsrouter only, leavingUsersopen. ConfirmUsersendpoints work without a header, whileProductsendpoints now require one. - Simulate versioning: Starting from Program 1’s project, duplicate the
usersrouter into a newapp/api/v2/routers/users.pywhereGET /users/also returns a newtotal_countfield alongside the user list. Register a second aggregator (api_router_v2withprefix="/api/v2") inmain.pyalongside the existing v1 router, and confirm both/api/v1/users/and/api/v2/users/work independently.
Summary
In this chapter, you built two complete, runnable FastAPI projects that scaled up your project’s routing structure:
- Organizing multiple resources into their own router files and combining them through a central, versioned aggregator (
/api/v1/...) - Applying shared configuration — authentication checks and documented responses — once, at the router level, instead of repeating it across dozens of individual routes
