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?

DirectoryResponsibilityTypical contents
app/The application's main packageHolds all the code
app/main.pyEntry pointCreate FastAPI, include routers, middleware
app/routers/Endpoints grouped by domainOne file per domain (tasks, users)
app/models/Pydantic modelsOne file per domain
app/dependencies/Dependency functionsGrouped by purpose (pagination, auth)
app/middleware/Custom middlewareLogging, timing, headers
tests/Automated testsMirrors 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?

FormWhen to use it
from app.models import TaskCreateDay to day — clean and short
from app.models.tasks import TaskCreateWhen 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?

CriterionBy layer (models/, routers/)By feature (tasks/, users/)
Small-to-medium projects✅ SimplerOverkill
Large projectsVery long files✅ Better organization
Small teams✅ Easy to navigate
Large teamsMerge 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.py only creates the app and includes routers — 10-20 lines
  • __init__.py controls 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

  1. FastAPI - Bigger Applications — The officially recommended structure
  2. Python - Packagesinit.py and packages
  3. Python - Import System — How the import system works
  4. Real Python - Python Modules and Packages — A practical guide to modules
  5. 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.