[FastAPI Series 7] Organize project routes with APIRouter

한국어 버전

As endpoints pile up, main.py becomes impossible to manage. APIRouter lets you group related paths, move them into modules, and reduce merge conflicts. Building on the user and task APIs created so far, this part walks through splitting files with APIRouter and keeping imports clean.

# Before (everything inside main.py)
app = FastAPI()

@app.post("/tasks")
def create_task(...):
    ...

@app.get("/tasks")
def list_tasks():
    ...

# After (lean main.py that just mounts routers)
app.include_router(tasks.router)
app.include_router(users.router)

Slimming down the entrypoint makes it obvious where new features live and keeps diffs isolated per team.

Key terms

  1. APIRouter: Groups related routes into a reusable module. It is the star of this article.
  2. include_router: Combines separate routers into a single FastAPI app.
  3. prefix: A string automatically added to every path (for example /tasks) so you do not repeat yourself.
  4. tags: Labels that show up in Swagger UI, making features easy to find when routers are separated.
  5. Dependencies: Router-level injections, such as authentication guards, that apply to every endpoint within that router.

Practice card

  • Estimated time: 40 minutes
  • Prereqs: Part 6 project structure, basic Python packages
  • Goal: Create an APIRouter, then attach it with include_router

Core ideas

  • Create an app/api folder and router module
  • Register paths on APIRouter and mount it on the main app
  • Declare shared prefixes and tags to tidy the docs

Folder layout example

my-fastapi-app/
├─ app/
│  ├─ __init__.py
│  ├─ main.py
│  └─ api/
│     ├─ __init__.py
│     └─ tasks.py
└─ pyproject.toml

app/main.py now focuses on creating the FastAPI instance and registering routers. The __init__.py files can stay empty—they simply tell Python to treat the folders as packages.

Define a router

app/api/tasks.py:

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

router = APIRouter(prefix="/tasks", tags=["tasks"])

class Task(BaseModel):
    title: str
    done: bool = False

tasks: list[dict] = []
current_id = 0

@router.post("", status_code=201)
def create_task(task: Task):
    global current_id
    current_id += 1
    record = {"id": current_id, **task.model_dump()}
    tasks.append(record)
    return record

@router.get("")
def list_tasks():
    return tasks

@router.get("/{task_id}")
def get_task(task_id: int):
    for t in tasks:
        if t["id"] == task_id:
            return t
    raise HTTPException(status_code=404, detail="Task not found")

Thanks to prefix="/tasks", you avoid repeating the base path, and Swagger UI places everything under the tasks tag.

⚠️ We keep using an in-memory list to stay consistent with earlier parts. Production apps should persist data via a database or service so the state does not disappear on restart.

Wire it up in the main file

app/main.py:

from fastapi import FastAPI
from app.api import tasks  # app/api/__init__.py re-exports the module

app = FastAPI()

app.include_router(tasks.router)

@app.get("/health")
def health_check():
    return {"status": "ok"}

Call include_router as many times as needed inside main.py before the app starts serving traffic. You can add router-level dependencies or responses to apply shared behavior.

Practical example: admin router (preview)

Once a single router feels comfortable, you can register multiple routers with different prefixes, tags, and dependency guards.

from fastapi import Depends
from app.api import tasks, admin

def check_admin(user = Depends(get_current_user)):
    if not user.is_admin:
        raise HTTPException(status_code=403)
    return user

app.include_router(tasks.router)
app.include_router(
    admin.router,
    prefix="/admin",
    tags=["admin"],
    dependencies=[Depends(check_admin)],
)

dependencies=[Depends(check_admin)] applies the guard to every route inside the admin router. We cover dependency injection in depth in Part 8, so treat this snippet as a preview rather than required reading.

uv commands and import paths

uv (the Python project runner) still launches the app the same way—just point to the reorganized module path: uv run fastapi dev app/main.py. When running Uvicorn directly, use uv run uvicorn app.main:app --reload, and run both commands from the project root so imports resolve.

Things to watch when splitting

  • Circular imports: if tasks.py imports users.py and users.py imports tasks.py, Python raises ImportError. Fix this by moving shared schemas into app/schemas.py and importing from there.
  • Shared config: create packages such as core, schemas, or services once routes need settings, database helpers, or business logic. Routers should stay thin.
  • Docs navigation: set meaningful tags so /docs remains scannable even when dozens of routers exist.

Practice

  • Follow along: Build app/api/tasks.py with APIRouter and register it from app/main.py.
  • Extend: Add an admin router with its own prefix and tag, then confirm /docs shows separate sections.
  • Debug: Fix execution errors caused by import loops or wrong module paths, or use the tip below.
  • Done when: Running uv run fastapi dev app/main.py shows multiple tags in /docs.
  • Troubleshoot: If you see ModuleNotFoundError: No module named 'app', run the command from the repository root so Python can find the package.

Wrap-up

APIRouter is the baseline tool for keeping FastAPI tidy. Splitting files plus declaring prefixes and tags instantly clarifies the structure. Next we will use the current codebase to prepare for testing and deployment flows.

💬 댓글

이 글에 대한 의견을 남겨주세요