Chapter 12: Routers — Organizing Routes into Modules with APIRouter

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.py and products.py each define their own APIRouter with their own prefix (/users, /products) and tags — exactly the pattern you’ve used since Chapter 5. Nothing new here individually; the new idea is how they get combined.
  • api/v1/api.py introduces the aggregator pattern: a single api_router with prefix="/api/v1", which then calls include_router() on every individual resource router. This means the final URLs become /api/v1/users/ and /api/v1/products/ — the /api/v1 prefix is applied automatically to everything nested inside, without repeating it in every router file.
  • main.py now only needs to include_router(api_router) once. If you add a tenth resource next month, main.py doesn’t change at all — you only touch api/v1/api.py to register the new router, and create the new router file itself.
  • Why this matters for versioning: imagine you need a /api/v2/users with breaking changes down the line. You’d create a parallel app/api/v2/ folder with its own api.py, and include both api_router_v1 and api_router_v2 in main.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:

  1. Confirm every endpoint now lives under /api/v1/... — e.g. POST /api/v1/users/ and GET /api/v1/products/.
  2. Confirm the Swagger UI still groups everything neatly under Users and Products tags, even though the URLs are nested under a shared prefix.
  3. 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_key is a plain FastAPI dependency function — it reads a required header called X-API-Key (FastAPI automatically maps x_api_key to the X-API-Key header) and raises 401 Unauthorized if 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 writing Depends(verify_api_key) on each one individually.
  • responses={401: {"description": "..."}} at the router level also documents that 401 response 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
  1. Open http://127.0.0.1:8000/docs and try calling GET /api/v1/admin/stats directly — it should fail with 401, since Swagger UI doesn’t send the header automatically.
  2. 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 a 200 with the stats.
  3. Try DELETE /api/v1/admin/cache the same way, first without the header (expect 401), then with it (expect 204).

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 keeps main.py almost 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=[...] and responses={...}, rather than repeating them on every route.
  • ✅ Plan for versioning early (/api/v1/...) even if you don’t need v2 yet — 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


Theory Questions

  1. What problem does the api/v1/api.py aggregator pattern solve as a project grows?
  2. Why does applying a prefix at the aggregator level (/api/v1) avoid repeating it in every individual router?
  3. What’s the practical benefit of putting an auth check in dependencies=[...] at the router level instead of inside each route function?
  4. Why is API versioning (/api/v1/...) something worth planning for early, rather than adding later?
  5. What’s the difference between what dependencies=[...] does at runtime versus what responses={...} does?

Practical Assignment

  1. Add a third resource: Starting from Program 1’s project, add an Orders resource (its own model, schema, service, and router file) and register it in api/v1/api.py, without touching main.py at all.
  2. Protect an existing router: Starting from Program 1’s project, apply the verify_api_key dependency (from Program 2) to the Products router only, leaving Users open. Confirm Users endpoints work without a header, while Products endpoints now require one.
  3. Simulate versioning: Starting from Program 1’s project, duplicate the users router into a new app/api/v2/routers/users.py where GET /users/ also returns a new total_count field alongside the user list. Register a second aggregator (api_router_v2 with prefix="/api/v2") in main.py alongside 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

Scroll to Top