Python for JS Devs · FastAPI

Structuring a real project

One main.py doesn't scale. Here's the router + settings layout real teams use — and how config beats process.env.

Lesson 16Time ~13 min Win Split routes into a router & typed settingsPrereq Lessons 08, 14

1 · APIRouter — Express's Router

Group related endpoints in their own module with an APIRouter, then mount them on the app. Same idea as express.Router().

# app/routers/users.py
from fastapi import APIRouter

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

@router.get("/{uid}")
def get_user(uid: int): ...

# app/main.py
from fastapi import FastAPI
from app.routers import users

app = FastAPI()
app.include_router(users.router)   # mount the group

2 · A layout that scales

app/
├── __init__.py
├── main.py            # create app, include routers
├── core/
│   └── config.py      # Settings (env)
├── routers/           # HTTP layer (thin)
│   ├── users.py
│   └── auth.py
├── schemas/           # Pydantic in/out models
│   └── user.py
├── services/          # business logic (HTTP-agnostic)
│   └── user_service.py
└── db/                # SQLAlchemy models + session
    └── models.py
The layering rule Routers stay thin: parse input, call a service, shape output. Services hold business logic and know nothing about HTTP (they raise domain errors from Lesson 09). This is the same separation you'd want in a good Express app — FastAPI just makes it natural.

3 · Typed settings beat process.env

No scattered process.env.FOO with undefined surprises. pydantic-settings loads env vars into a validated, typed object and fails fast at startup if something's missing.

Node
const url = process.env.DATABASE_URL;
// string | undefined, no validation
const port = +process.env.PORT;
// NaN if unset 🙃
FastAPI
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    port: int = 8000
    model_config = {"env_file": ".env"}

settings = Settings()   # validates at import; missing → error

Add it with uv add pydantic-settings. Now settings.database_url is typed and guaranteed present. Inject settings anywhere (often via a Depends).

🎯 Your win

Refactor a two-route app: move the routes into app/routers/users.py behind an APIRouter(prefix="/users"), mount it in main.py with include_router, and add a Settings class reading APP_NAME from a .env. Run it and confirm /docs shows the grouped, tagged routes. A structured, config-driven app is the win.

Check yourself

Q1. To group and mount a set of routes you use:
Define an APIRouter per module and mount it via app.include_router(...).
Q2. In good layering, business logic should live in:
Routers stay thin; services hold logic and don't know about HTTP.
Q3. The advantage of pydantic-settings over raw env vars is:
It parses env into a typed object and errors at startup if required values are missing.
Primary source FastAPI — Bigger Applications and Pydantic Settings.
💬 Ask your teacher: where should lru_cache go for settings, and do I need it? (Your old code used it — ask what problem it solves.)