Module 2: APIRouter and Middleware
A Professional Folder Structure — Organizing a FastAPI Project
Capsule overview
Knowing how to use APIRouter is one step. Knowing where to put each file is the step that separates a prototype from a professional project. In this capsule you'll learn the folder structure that FastAPI projects use in production: app/routers/ for endpoints, app/models/ for Pydantic models, app/dependencies/ for dependency functions, app/middleware/ for custom middleware.
It isn't organization for the sake of aesthetics. A good structure means any developer can navigate your project without an explanation: "Where are the task endpoints?" → app/routers/tasks.py. "Where's the TaskCreate model?" → app/models/tasks.py. "Where's the pagination dependency?" → app/dependencies/pagination.py.
You'll also see how __init__.py lets you control your imports and create clean interfaces between modules.
The structure we're going to build
fastapi-advanced/
├── app/
│ ├── __init__.py
│ ├── main.py ← Entry point: creates the app, includes routers
│ ├── routers/
│ │ ├── __init__.py ← Exports the routers
│ │ ├── root.py ← General endpoints (/, /health)
│ │ └── tasks.py ← Task endpoints
│ ├── models/
│ │ ├── __init__.py ← Exports the models
│ │ └── tasks.py ← TaskCreate, TaskUpdate, TaskResponse, etc.
│ ├── dependencies/
│ │ ├── __init__.py ← Exports the dependencies
│ │ ├── pagination.py ← pagination_params, Paginator
│ │ ├── tasks.py ← get_task_or_404, task_filters
│ │ └── data.py ← get_task_store (data access)
│ └── middleware/
│ ├── __init__.py
│ └── logging.py ← Timing and logging middleware
├── tests/
│ ├── __init__.py
│ └── test_tasks.py
├── requirements.txt
└── venv/
Why this structure?
| Directory | Responsibility | Typical contents |
|---|---|---|
app/ | The application's main package | Holds all the code |
app/main.py | Entry point | Create FastAPI, include routers, middleware |
app/routers/ | Endpoints grouped by domain | One file per domain (tasks, users) |
app/models/ | Pydantic models | One file per domain |
app/dependencies/ | Dependency functions | Grouped by purpose (pagination, auth) |
app/middleware/ | Custom middleware | Logging, timing, headers |
tests/ | Automated tests | Mirrors the structure of app/ |
Step by step: from main.py to a modular structure
Step 1: Create the directories
cd fastapi-advanced
mkdir -p app/routers app/models app/dependencies app/middleware tests
touch app/routers/__init__.py app/models/__init__.py app/dependencies/__init__.py app/middleware/__init__.py tests/__init__.py
Step 2: Extract the models into app/models/tasks.py
# app/models/tasks.py
from typing import Optional
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200, description="The task's title")
description: Optional[str] = Field(default=None, max_length=1000)
priority: str = Field(default="medium", pattern="^(high|medium|low)$")
model_config = {
"json_schema_extra": {
"examples": [
{
"title": "Study advanced FastAPI",
"description": "Finish the APIRouter module",
"priority": "high",
}
]
}
}
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=1000)
completed: Optional[bool] = None
priority: Optional[str] = Field(default=None, pattern="^(high|medium|low)$")
class PaginationResult(BaseModel):
skip: int
limit: int
class TaskFilters(BaseModel):
completed: Optional[bool]
priority: Optional[str]
search: Optional[str]
Step 3: Export from init.py
# app/models/__init__.py
from app.models.tasks import TaskCreate, TaskUpdate, PaginationResult, TaskFilters
Now you can import like this:
from app.models import TaskCreate, TaskUpdate
Instead of:
from app.models.tasks import TaskCreate, TaskUpdate
Both work. The __init__.py gives you the option of shorter imports.
Step 4: Extract the data store into app/dependencies/data.py
# app/dependencies/data.py
from datetime import datetime
initial_tasks = [
{
"id": 1,
"title": "Buy groceries",
"description": "Milk, bread, eggs, fruit",
"completed": False,
"priority": "medium",
"created_at": "2026-03-10T09:00:00",
},
{
"id": 2,
"title": "Study Dependency Injection",
"description": "Finish capsules 02 through 04 of module 1",
"completed": False,
"priority": "high",
"created_at": "2026-03-10T10:30:00",
},
{
"id": 3,
"title": "Work out",
"description": None,
"completed": True,
"priority": "low",
"created_at": "2026-03-10T07:00:00",
},
{
"id": 4,
"title": "Review pull requests",
"description": "PR #42 and PR #43 waiting for review",
"completed": False,
"priority": "high",
"created_at": "2026-03-11T14:00:00",
},
{
"id": 5,
"title": "Prepare presentation",
"description": "Slides for Friday's demo",
"completed": True,
"priority": "medium",
"created_at": "2026-03-09T16:00:00",
},
]
task_store = initial_tasks[:]
def get_task_store():
yield task_store
Step 5: Extract the dependencies into app/dependencies/pagination.py and tasks.py
# app/dependencies/pagination.py
from fastapi import Query
from app.models import PaginationResult
def pagination_params(
skip: int = Query(default=0, ge=0, description="Records to skip"),
limit: int = Query(default=10, ge=1, le=100, description="Maximum records"),
) -> PaginationResult:
return PaginationResult(skip=skip, limit=limit)
class Paginator:
def __init__(self, max_limit: int = 100):
self.max_limit = max_limit
def __call__(
self,
page: int = Query(default=1, ge=1, description="Page number"),
size: int = Query(default=10, ge=1, description="Page size"),
) -> PaginationResult:
effective_size = min(size, self.max_limit)
skip = (page - 1) * effective_size
return PaginationResult(skip=skip, limit=effective_size)
# app/dependencies/tasks.py
from typing import Optional
from fastapi import Depends, HTTPException, Query
from app.models import TaskFilters
from app.dependencies.data import get_task_store
def task_filters(
completed: Optional[bool] = Query(default=None, description="Filter by status"),
priority: Optional[str] = Query(
default=None, pattern="^(high|medium|low)$", description="Filter by priority"
),
search: Optional[str] = Query(
default=None, min_length=1, description="Search in title and description"
),
) -> TaskFilters:
return TaskFilters(completed=completed, priority=priority, search=search)
def get_task_or_404(
task_id: int,
store: list = Depends(get_task_store),
) -> dict:
for task in store:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
Step 6: Export the dependencies
# app/dependencies/__init__.py
from app.dependencies.pagination import pagination_params, Paginator
from app.dependencies.tasks import task_filters, get_task_or_404
from app.dependencies.data import get_task_store
Step 7: Create the routers
# app/routers/root.py
from fastapi import APIRouter
router = APIRouter(tags=["General"])
@router.get("/")
def root():
return {
"service": "To-Do API",
"version": "2.0.0",
"docs": "/docs",
}
@router.get("/health")
def health():
return {"status": "healthy"}
# app/routers/tasks.py
from datetime import datetime
from fastapi import APIRouter, Depends
from app.models import TaskCreate, TaskUpdate, PaginationResult, TaskFilters
from app.dependencies import pagination_params, task_filters, get_task_or_404, get_task_store
router = APIRouter(prefix="/tasks", tags=["Tasks"])
def apply_filters(tasks_list: list, filters: TaskFilters) -> list:
result = tasks_list[:]
if filters.completed is not None:
result = [t for t in result if t["completed"] == filters.completed]
if filters.priority:
result = [t for t in result if t["priority"] == filters.priority]
if filters.search:
term = filters.search.lower()
result = [
t for t in result
if term in t["title"].lower()
or (t["description"] and term in t["description"].lower())
]
return result
@router.get("/")
def list_tasks(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
pagination: PaginationResult = Depends(pagination_params),
):
filtered = apply_filters(store, filters)
start = pagination.skip
end = start + pagination.limit
return {
"total": len(filtered),
"skip": pagination.skip,
"limit": pagination.limit,
"tasks": filtered[start:end],
}
@router.get("/stats")
def task_stats(
store: list = Depends(get_task_store),
filters: TaskFilters = Depends(task_filters),
):
filtered = apply_filters(store, filters)
completed_count = sum(1 for t in filtered if t["completed"])
by_priority = {}
for task in filtered:
p = task["priority"]
by_priority[p] = by_priority.get(p, 0) + 1
return {
"total": len(filtered),
"completed": completed_count,
"pending": len(filtered) - completed_count,
"by_priority": by_priority,
}
@router.get("/{task_id}")
def get_task(task: dict = Depends(get_task_or_404)):
return task
@router.post("/", status_code=201)
def create_task(
task_data: TaskCreate,
store: list = Depends(get_task_store),
):
new_id = max((t["id"] for t in store), default=0) + 1
new_task = task_data.model_dump()
new_task["id"] = new_id
new_task["completed"] = False
new_task["created_at"] = datetime.now().isoformat()
store.append(new_task)
return new_task
@router.patch("/{task_id}")
def update_task(
task: dict = Depends(get_task_or_404),
task_data: TaskUpdate = ...,
):
update = task_data.model_dump(exclude_unset=True)
task.update(update)
return task
@router.delete("/{task_id}")
def delete_task(
task: dict = Depends(get_task_or_404),
store: list = Depends(get_task_store),
):
store.remove(task)
return {"message": f"Task '{task['title']}' deleted", "task": task}
Step 8: Export the routers
# app/routers/__init__.py
from app.routers.root import router as root_router
from app.routers.tasks import router as tasks_router
Step 9: Simplify main.py
# app/main.py
from fastapi import FastAPI
from app.routers import root_router, tasks_router
app = FastAPI(
title="To-Do API",
description="A modular task API. Module 2 — FastAPI Advanced Features.",
version="2.0.0",
)
app.include_router(root_router)
app.include_router(tasks_router)
12 lines. That's your entire main.py. It creates the app and includes the routers. The logic lives in its own files.
The init.py pattern
What does init.py do?
__init__.py turns a directory into a Python package. On top of that, it controls what gets exported when someone writes from app.models import ...:
# app/models/__init__.py
from app.models.tasks import TaskCreate, TaskUpdate, PaginationResult, TaskFilters
That enables:
# ✅ Short import (from the package)
from app.models import TaskCreate
# ✅ Explicit import (from the file)
from app.models.tasks import TaskCreate
When to use which import?
| Form | When to use it |
|---|---|
from app.models import TaskCreate | Day to day — clean and short |
from app.models.tasks import TaskCreate | When there are name conflicts between files |
An empty init.py vs. one with exports
An empty __init__.py turns the directory into a package but exports nothing. An __init__.py with imports re-exports the symbols it lists.
The convention: use __init__.py with explicit exports for packages that other modules import often (models, dependencies). Leave it empty for packages where direct imports are clearer (middleware).
Import rules: avoiding circular dependencies
The import flow has to be one-directional:
app/models/ ← Imports from nobody (just Pydantic/stdlib)
↑
app/dependencies/ ← Imports from models
↑
app/routers/ ← Imports from models and dependencies
↑
app/main.py ← Imports from routers (and middleware)
What NOT to do
# ❌ CIRCULAR: dependencies imports from routers
# app/dependencies/tasks.py
from app.routers.tasks import router # ← NEVER
# ❌ CIRCULAR: models imports from dependencies
# app/models/tasks.py
from app.dependencies.pagination import pagination_params # ← NEVER
And if I need to share something between routers?
Use dependencies or a shared module. Routers must never import from other routers.
# ❌ A router importing from another router
# app/routers/stats.py
from app.routers.tasks import tasks # ← NEVER
# ✅ Both use a shared dependency
# app/routers/tasks.py
from app.dependencies.data import get_task_store
# app/routers/stats.py
from app.dependencies.data import get_task_store
Structure variations
The structure we showed is the most common one, but there are legitimate variations:
Structure by feature (an alternative)
app/
├── main.py
├── tasks/
│ ├── __init__.py
│ ├── router.py
│ ├── models.py
│ ├── dependencies.py
│ └── service.py
├── users/
│ ├── __init__.py
│ ├── router.py
│ ├── models.py
│ ├── dependencies.py
│ └── service.py
└── shared/
├── __init__.py
├── pagination.py
└── middleware.py
In this variation, each feature keeps everything of its own together. It's useful for large projects with teams working on independent features.
Which one should you pick?
| Criterion | By layer (models/, routers/) | By feature (tasks/, users/) |
|---|---|---|
| Small-to-medium projects | ✅ Simpler | Overkill |
| Large projects | Very long files | ✅ Better organization |
| Small teams | ✅ Easy to navigate | — |
| Large teams | Merge conflicts | ✅ Independent teams |
For this module and for most FastAPI projects, the by-layer structure is the recommended one.
Exercises
Exercise 1: Create the folder structure (Easy)
Create the complete folder structure for a notes API project: app/routers/, app/models/, app/dependencies/. Create every __init__.py. Create a NoteCreate model in app/models/notes.py and export it from __init__.py.
See solution
mkdir -p app/routers app/models app/dependencies
touch app/__init__.py app/routers/__init__.py app/models/__init__.py app/dependencies/__init__.py
app/models/notes.py:
from typing import Optional
from pydantic import BaseModel, Field
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
category: str = Field(default="general")
class NoteUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
content: Optional[str] = Field(default=None, min_length=1)
category: Optional[str] = None
app/models/__init__.py:
from app.models.notes import NoteCreate, NoteUpdate
Check it:
# From any module
from app.models import NoteCreate, NoteUpdate
Exercise 2: Move a dependency into a file (Easy)
Take pagination_params and move it to app/dependencies/pagination.py. Export it from app/dependencies/__init__.py. Check that you can import it from a router with from app.dependencies import pagination_params.
See solution
app/dependencies/pagination.py:
from fastapi import Query
def pagination_params(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=10, ge=1, le=100),
) -> dict:
return {"skip": skip, "limit": limit}
app/dependencies/__init__.py:
from app.dependencies.pagination import pagination_params
app/routers/notes.py:
from fastapi import APIRouter, Depends
from app.dependencies import pagination_params
router = APIRouter(prefix="/notes", tags=["Notes"])
notes = [{"id": 1, "title": "Note 1"}, {"id": 2, "title": "Note 2"}]
@router.get("/")
def list_notes(pagination: dict = Depends(pagination_params)):
start = pagination["skip"]
end = start + pagination["limit"]
return notes[start:end]
Exercise 3: Create a router in a separate file (Medium)
Create app/routers/notes.py with a router (prefix /notes, tag "Notes"), GET / and POST /. Import the models from app/models/ and the dependencies from app/dependencies/. Include it in app/main.py.
See solution
app/models/notes.py:
from pydantic import BaseModel, Field
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
app/models/__init__.py:
from app.models.notes import NoteCreate
app/dependencies/notes.py:
from fastapi import HTTPException
notes_store = [
{"id": 1, "title": "First note", "content": "Hello world"},
{"id": 2, "title": "Second note", "content": "FastAPI is great"},
]
def get_notes_store():
yield notes_store
def get_note_or_404(note_id: int) -> dict:
for note in notes_store:
if note["id"] == note_id:
return note
raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
app/dependencies/__init__.py:
from app.dependencies.pagination import pagination_params
from app.dependencies.notes import get_notes_store, get_note_or_404
app/routers/notes.py:
from fastapi import APIRouter, Depends
from app.models import NoteCreate
from app.dependencies import get_notes_store, pagination_params
router = APIRouter(prefix="/notes", tags=["Notes"])
@router.get("/")
def list_notes(
store: list = Depends(get_notes_store),
pagination: dict = Depends(pagination_params),
):
start = pagination["skip"]
end = start + pagination["limit"]
return {"total": len(store), "notes": store[start:end]}
@router.post("/", status_code=201)
def create_note(data: NoteCreate, store: list = Depends(get_notes_store)):
new_id = max((n["id"] for n in store), default=0) + 1
note = data.model_dump()
note["id"] = new_id
store.append(note)
return note
app/routers/__init__.py:
from app.routers.notes import router as notes_router
app/main.py:
from fastapi import FastAPI
from app.routers import notes_router
app = FastAPI(title="Notes API")
app.include_router(notes_router)
@app.get("/", tags=["General"])
def root():
return {"service": "Notes API"}
Exercise 4: Check your imports for circles (Medium)
Draw your project's import graph. Verify that the flow is one-directional: models ← dependencies ← routers ← main. If you find a circular import, identify it and fix it.
See solution
For the project from exercise 3:
app/models/notes.py → only imports from pydantic (✅ no internal dependencies)
app/dependencies/notes.py → imports from fastapi (✅ no app/ deps)
app/dependencies/pagination.py → imports from fastapi (✅)
app/routers/notes.py → imports from app.models and app.dependencies (✅ one-directional)
app/main.py → imports from app.routers (✅ one-directional)
The graph:
pydantic, fastapi (stdlib/external)
↑
app/models/
↑
app/dependencies/
↑
app/routers/
↑
app/main.py
If app/dependencies/notes.py imported from app/routers/notes.py, that would be circular. The fix: move the data store into a neutral module like app/dependencies/data.py.
Exercise 5: A complete structure with two domains (Hard)
Create a project with two domains: tasks and categories. Each with: a model in app/models/, a dependency in app/dependencies/, a router in app/routers/. main.py only includes routers. Check that both work in /docs.
See solution
app/models/tasks.py:
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
category_id: int = Field(ge=1)
app/models/categories.py:
from pydantic import BaseModel, Field
class CategoryCreate(BaseModel):
name: str = Field(min_length=1, max_length=50)
app/models/__init__.py:
from app.models.tasks import TaskCreate
from app.models.categories import CategoryCreate
app/dependencies/data.py:
categories = [
{"id": 1, "name": "Work"},
{"id": 2, "name": "Personal"},
]
tasks = [
{"id": 1, "title": "Deploy API", "category_id": 1},
{"id": 2, "title": "Buy groceries", "category_id": 2},
]
def get_task_store():
yield tasks
def get_category_store():
yield categories
app/dependencies/tasks.py:
from fastapi import Depends, HTTPException
from app.dependencies.data import get_task_store
def get_task_or_404(task_id: int, store: list = Depends(get_task_store)) -> dict:
for task in store:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
app/dependencies/categories.py:
from fastapi import Depends, HTTPException
from app.dependencies.data import get_category_store
def get_category_or_404(category_id: int, store: list = Depends(get_category_store)) -> dict:
for cat in store:
if cat["id"] == category_id:
return cat
raise HTTPException(status_code=404, detail=f"Category {category_id} not found")
app/dependencies/__init__.py:
from app.dependencies.data import get_task_store, get_category_store
from app.dependencies.tasks import get_task_or_404
from app.dependencies.categories import get_category_or_404
app/routers/tasks.py:
from fastapi import APIRouter, Depends
from app.models import TaskCreate
from app.dependencies import get_task_store, get_task_or_404
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
def list_tasks(store: list = Depends(get_task_store)):
return store
@router.get("/{task_id}")
def get_task(task: dict = Depends(get_task_or_404)):
return task
@router.post("/", status_code=201)
def create_task(data: TaskCreate, store: list = Depends(get_task_store)):
new_id = max((t["id"] for t in store), default=0) + 1
task = data.model_dump()
task["id"] = new_id
store.append(task)
return task
app/routers/categories.py:
from fastapi import APIRouter, Depends
from app.models import CategoryCreate
from app.dependencies import get_category_store, get_category_or_404
router = APIRouter(prefix="/categories", tags=["Categories"])
@router.get("/")
def list_categories(store: list = Depends(get_category_store)):
return store
@router.get("/{category_id}")
def get_category(cat: dict = Depends(get_category_or_404)):
return cat
@router.post("/", status_code=201)
def create_category(data: CategoryCreate, store: list = Depends(get_category_store)):
new_id = max((c["id"] for c in store), default=0) + 1
cat = data.model_dump()
cat["id"] = new_id
store.append(cat)
return cat
app/routers/__init__.py:
from app.routers.tasks import router as tasks_router
from app.routers.categories import router as categories_router
app/main.py:
from fastapi import FastAPI
from app.routers import tasks_router, categories_router
app = FastAPI(title="Task Manager")
app.include_router(tasks_router)
app.include_router(categories_router)
@app.get("/", tags=["General"])
def root():
return {"service": "Task Manager"}
Troubleshooting
Problem 1: "ModuleNotFoundError: No module named 'app'"
Cause: You're running uvicorn from the wrong directory, or an __init__.py is missing.
# ❌ Running from inside app/
cd app
uvicorn main:app # it can't find the app module
# ✅ Running from the project root
cd fastapi-advanced
uvicorn app.main:app --reload
Problem 2: "ImportError: cannot import name 'TaskCreate' from 'app.models'"
Cause: You didn't export TaskCreate in app/models/__init__.py.
# ❌ An empty __init__.py
# app/models/__init__.py
# (empty)
# ✅ An __init__.py with exports
# app/models/__init__.py
from app.models.tasks import TaskCreate, TaskUpdate
Problem 3: Circular import error
Cause: Two modules import each other.
Diagnosis: Python tells you which module can't import what. Look for the cycle.
Fix: Move the shared code into a third module that doesn't import from either of the two.
Problem 4: Changes to a file aren't showing up
Cause: uvicorn with --reload sometimes doesn't pick up changes in new files.
# Restart it by hand
# Ctrl+C and then
uvicorn app.main:app --reload
Problem 5: A router doesn't show up in /docs
Cause: You never called app.include_router() in main.py.
# ❌ You only import it, you don't include it
from app.routers import tasks_router
# ✅ Include it explicitly
app.include_router(tasks_router)
Summary
- The professional structure separates:
routers/,models/,dependencies/,middleware/ main.pyonly creates the app and includes routers — 10-20 lines__init__.pycontrols exports and enables short imports- The import flow is one-directional:
models ← dependencies ← routers ← main - Circular imports get solved by extracting into a neutral module
- There are two patterns: by layer (recommended) and by feature (large projects)
- Every file should have one clear responsibility
- Shared data lives in
dependencies/data.py, not in routers
Additional resources
- FastAPI - Bigger Applications — The officially recommended structure
- Python - Packages — init.py and packages
- Python - Import System — How the import system works
- Real Python - Python Modules and Packages — A practical guide to modules
- FastAPI Best Practices — A collection of recommended patterns
What's next?
Next capsule: Custom Middleware and Events — You'll add a timing middleware (how long each request takes), a logging middleware (which endpoint was called, what status it returned), and lifespan events to initialize and clean up resources when the app boots and stops.