Lesson 5 - Guided Project: Modular App

Welcome to the Modular Task Manager

This is the fourth version of the course-long Task Manager, and it’s the one where the project finally grows up. So far everything has lived in a single main.py: the models, the routes, the shared logic, all stacked in one file. That’s perfect while you’re learning, but real projects don’t stay that small. In this guided project you’ll take everything from Module 4 — APIRouter, dependency injection, and middleware — and use it to reorganize the app into a clean, professional layout: a slim main.py that wires things together, a routers/ package that holds your endpoints, and a dependencies.py for shared logic. The behavior stays the same; the structure becomes something you’d be happy to hand to a teammate.

By the end of this project, you will be able to:

  • Lay out a FastAPI project across main.py, a routers/ package, and a dependencies.py module
  • Move the Tasks endpoints into an APIRouter with a prefix and tags
  • Share pagination across endpoints with a single injected dependency
  • Add custom middleware and CORS, then verify the whole assembled app with TestClient

We’ll build it stage by stage, then run the finished app end to end. Let’s begin.


Stage 1: The Project Layout

A growing FastAPI app benefits from a predictable folder structure. Here’s the layout we’re building toward — small enough to understand at a glance, but organized the way larger projects are:

task_manager/
├── main.py            # creates the app, adds middleware/CORS, includes routers
├── dependencies.py    # shared logic (the pagination dependency)
└── routers/
    ├── __init__.py    # makes routers/ a package
    └── tasks.py       # the tasks APIRouter (models, data, endpoints)

The idea is separation of concerns. main.py is the assembly point — it doesn’t define endpoints itself, it just builds the app and plugs in the pieces. dependencies.py holds logic that more than one router might need. And each file under routers/ owns one slice of the API. Add a users feature later and it’s a new routers/users.py plus one line in main.py — nothing else has to change.

The __init__.py file (it can be empty) is what makes routers/ an importable Python package, so you can write from routers.tasks import router.


Stage 2: The Tasks Router and Shared Dependency

Start with the shared logic. The pagination dependency is a plain function that reads two query parameters and returns them — exactly the kind of thing multiple list endpoints will want, so it lives on its own:

# dependencies.py

def pagination(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit}

Now the tasks router. Instead of decorating endpoints with @app.get(...), you create an APIRouter and decorate with @router.get(...). Giving the router prefix="/tasks" means every route inside it is automatically served under /tasks, and tags=["tasks"] groups them together in the /docs page. The data stays in memory for now (Module 5 swaps this for a real database):

# routers/tasks.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

from dependencies import pagination


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


tasks_db = [
    Task(id=1, title="Write project layout", done=True),
    Task(id=2, title="Add tasks router", done=False),
    Task(id=3, title="Wire up pagination dependency", done=False),
    Task(id=4, title="Add middleware and CORS", done=False),
    Task(id=5, title="Verify with TestClient", done=False),
]

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


@router.get("", response_model=list[Task])
def list_tasks(page: dict = Depends(pagination)):
    start = page["skip"]
    end = start + page["limit"]
    return tasks_db[start:end]


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

Two things to notice. The list endpoint uses @router.get("") (an empty path) so it lands exactly at the prefix, /tasks — and it gets its skip/limit for free by injecting Depends(pagination). The detail endpoint is @router.get("/{task_id}"), which becomes /tasks/{task_id}, and raises a 404 when no task matches. The router knows nothing about middleware or CORS — that’s main.py’s job.


Stage 3: Middleware and CORS in main.py

Now the assembly point. main.py creates the FastAPI app, attaches cross-cutting behavior, and includes the router. Two pieces of middleware here: built-in CORS so a browser front end on a known origin can call the API, and a custom middleware that stamps every response with a couple of headers — who handled it, and how long it took.

# main.py
import time

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from routers.tasks import router as tasks_router

app = FastAPI(title="Task Manager")

# Allow a browser front end on this origin to call the API
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://datatweets.com"],
    allow_methods=["*"],
    allow_headers=["*"],
)


# Stamp every response with custom headers
@app.middleware("http")
async def add_handled_by(request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Handled-By"] = "datatweets"
    response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.6f}"
    return response


# Plug the tasks router into the app
app.include_router(tasks_router)

add_middleware(CORSMiddleware, ...) tells the app to send the right Access-Control-Allow-* headers when a request arrives from https://datatweets.com. The custom middleware wraps each request: it records a start time, calls call_next(request) to run the route, then adds X-Handled-By and an X-Process-Time header to the response before returning it. Finally app.include_router(tasks_router) merges all the router’s endpoints into the app — at this point /tasks and /tasks/{task_id} become live routes.

That’s the whole app: three small files, each with one job.

In-memory now, a real database in Module 5

The tasks still live in a Python list, so they reset every time the server restarts. That’s fine for this structure-focused project — Module 5 replaces tasks_db with a real database using SQLModel, and because the data access is already isolated in routers/tasks.py, that change touches one file. Note too that the wiring happens at import time: when main.py imports the router and calls include_router, FastAPI builds the route table once at startup. This layout scales cleanly — each new feature is a new file in routers/ plus a single include_router line.


Stage 4: Verify the Assembled App

The reward for a clean structure is that the app is easy to test as a whole. TestClient runs the real app in-process — no server needed — so you can confirm every piece works together: pagination flows through the dependency, the prefix is applied, the 404 fires, and both the custom and CORS headers come back.

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

# Paginated list — skip 1, take 2
r = client.get("/tasks?skip=1&limit=2")
print(r.status_code, r.json())

# A single task by id
r = client.get("/tasks/3")
print(r.status_code, r.json())

# A missing task -> 404
r = client.get("/tasks/999")
print(r.status_code, r.json())

# Custom middleware headers present on the response
r = client.get("/tasks/1")
print("X-Handled-By:", r.headers.get("x-handled-by"))
print("X-Process-Time:", r.headers.get("x-process-time"))

# CORS header returned for the allowed origin
r = client.get("/tasks", headers={"Origin": "https://datatweets.com"})
print("access-control-allow-origin:", r.headers.get("access-control-allow-origin"))
200 [{'id': 2, 'title': 'Add tasks router', 'done': False}, {'id': 3, 'title': 'Wire up pagination dependency', 'done': False}]
200 {'id': 3, 'title': 'Wire up pagination dependency', 'done': False}
404 {'detail': 'Task not found'}
X-Handled-By: datatweets
X-Process-Time: 0.001431
access-control-allow-origin: https://datatweets.com

Every layer did its job. The skip=1&limit=2 request returned tasks 2 and 3, proving the pagination dependency injected the query parameters into the list endpoint. /tasks/3 resolved through the prefixed detail route, and /tasks/999 raised the 404. The custom middleware added X-Handled-By and X-Process-Time to the response, and CORS echoed the allowed origin back. You can also confirm the prefix landed correctly by inspecting the generated schema:

print(sorted(client.get("/openapi.json").json()["paths"].keys()))
['/tasks', '/tasks/{task_id}']

Both paths carry the /tasks prefix the router added — exactly what you’d see grouped under the tasks tag in the interactive docs.

To run it for real, start the server from the project folder and open the docs:

uvicorn main:app --reload

Then visit http://127.0.0.1:8000/docs. You’ll see both task routes grouped under the tasks tag, with skip and limit shown as query parameters on the list endpoint (FastAPI picked those up straight from the dependency). Try them out right in the browser — every response will carry your X-Handled-By and X-Process-Time headers.


Practice Exercises

Exercise 1: Add a second router

Create routers/users.py with router = APIRouter(prefix="/users", tags=["users"]), give it a GET "" list endpoint that also injects Depends(pagination), and include it in main.py. How many lines does main.py need to add?

Hint

Just two: from routers.users import router as users_router and app.include_router(users_router). Because the prefix lives on the router, the users routes land at /users automatically and share the very same pagination dependency from dependencies.py — no copy-paste. That’s the payoff of the layout.

Exercise 2: Add a router-level dependency

Suppose every tasks route should require a valid X-API-Key header. Instead of adding Depends(...) to each endpoint, attach the check once to the whole router. How?

Hint

Pass it when you create the router: APIRouter(prefix="/tasks", tags=["tasks"], dependencies=[Depends(verify_api_key)]). A dependency listed there runs before every route in the router. Use the dependencies=[...] form (not a parameter) when you only need the side effect — the check that raises 401 — and don’t need its return value inside the endpoint.

Exercise 3: A timing-only middleware

Right now one middleware adds both X-Handled-By and X-Process-Time. Split out the timing into its own @app.middleware("http") function that only adds X-Process-Time. What order do stacked middlewares run in?

Hint

Middleware wraps the request like layers of an onion: the last one you add runs outermost (first on the way in, last on the way out). Each one calls await call_next(request) to pass control inward, then edits the response on the way back out. As long as both functions set their own header, both headers appear on the final response regardless of order.


Summary

You restructured the Task Manager from a single file into a clean, modular app. The pagination logic moved into dependencies.py; the task endpoints moved into a routers/tasks.py APIRouter with prefix="/tasks" and tags=["tasks"]; and main.py became a slim assembly point that adds CORS and a custom header-stamping middleware, then plugs the router in with include_router. You verified the whole thing with TestClient — paginated list, single task, a 404, the custom X-Handled-By/X-Process-Time headers, and the CORS allow-origin header — and saw the /tasks prefix reflected in the OpenAPI schema. The data is still in memory, which Module 5 will fix.

Key Concepts

  • Project layoutmain.py assembles, routers/ owns endpoints, dependencies.py holds shared logic.
  • APIRouter — groups related routes; prefix and tags apply to all of them.
  • include_router — merges a router’s endpoints into the app at import time.
  • Shared dependencyDepends(pagination) reused by list endpoints across routers.
  • Middleware + CORS — cross-cutting behavior added once in main.py, applied to every response.

Why This Matters

A flat main.py is fine for a demo and miserable for a real product. The layout you built here — slim app file, a routers package, shared dependencies, and middleware in one place — is how production FastAPI projects are organized. It keeps each feature self-contained, makes new features a one-line addition, and keeps cross-cutting concerns like CORS and request logging from being scattered. It’s also exactly the foundation Module 5 needs: with data access already isolated in one router file, swapping the in-memory list for a real database is a contained change rather than a rewrite.


Next Steps

Continue to Module 5 - Databases with SQLModel

Time to make it real: replace the in-memory list with a proper database using SQLModel, so your tasks finally persist between restarts.

Back to Module Overview

Return to the Structure, Dependencies, and Middleware module overview


Continue Building Your Skills

You can now structure a FastAPI app the way professionals do: routers in their own files, shared logic in dependencies, and middleware and CORS wired up in a clean main.py. That structure is more than tidiness — it’s what lets a project grow without turning into a tangle. Next, in Module 5, you’ll give the Task Manager a real memory: a database that keeps your tasks safe across restarts, slotted neatly into the layout you just built.