Module 2 · FastAPI · Deep Dive
The patterns that turn a one-file demo into a real backend — dependency injection, routers, async done right, CORS, background work, config, and tests. This is the lesson that makes DocChat production-shaped.
BasicIntermediateBuild
main.py falls apart. This lesson is the bridge from "it runs" to "it's hireable" — every pattern here is something a UAE interviewer will probe, and something you'll wire into the capstone within the hour.
Dependsyield (setup/teardown)APIRouterBackgroundTasksTestClient + pytestDependsA dependency is just a function FastAPI runs before your endpoint, passing the result in as an argument. You declare what you need; FastAPI builds it and hands it over. That's the whole idea — and it's the most important pattern in the framework.
from fastapi import Depends, FastAPI app = FastAPI() # a dependency is just a function def pagination(skip: int = 0, limit: int = 20): return {"skip": skip, "limit": limit} @app.get("/documents") def list_docs(page: dict = Depends(pagination)): return page # {"skip": 0, "limit": 20}
The win is reuse: write the logic once, inject it into ten endpoints. The classic example is a database session. You don't want every route opening and closing a connection by hand — you write one get_db dependency and let FastAPI thread it everywhere.
from sqlalchemy.orm import Session
def get_db():
db = SessionLocal() # open a session
return db
@app.get("/documents/{doc_id}")
def read_doc(doc_id: int, db: Session = Depends(get_db)):
return db.query(Document).get(doc_id)
PHP bridge: if you ever used a service container (Laravel's app()->make(), Symfony's autowiring), this is the same instinct — declare the contract, let the framework construct it. The difference: in FastAPI it's plain functions and type hints, no config files.
app.dependency_overrides[get_db] = fake_db and swap in a stub with one line. No mocking libraries, no monkey-patching globals. The dependency is the seam.yieldThe get_db above has a bug: it never closes the session. The fix is a dependency with yield. Code before the yield is setup; code after it is teardown, and FastAPI runs it after the response is sent — even if the endpoint raised.
def get_db():
db = SessionLocal()
try:
yield db # hand the session to the endpoint
finally:
db.close() # always runs — guaranteed cleanup
This is the right shape for anything that needs paired setup/teardown: database sessions, file handles, network clients. The try/finally guarantees the resource is released no matter what happens inside the request.
yield dependency is like a context manager (with open(...) as f:) stretched across the whole request. Everything before yield = entering the with; everything after = exiting it.
APIRouterOne main.py with forty routes is unmaintainable. APIRouter lets you split routes into modules — think of it as a mini-FastAPI you mount onto the main app.
app/routers/documents.py
from fastapi import APIRouter, Depends from app.db import get_db router = APIRouter(prefix="/documents", tags=["documents"]) @router.get("/") def list_docs(db = Depends(get_db)): return db.query(Document).all() @router.post("/") def create_doc(title: str, db = Depends(get_db)): ...
Then an app factory — a function that builds and returns the app — wires the routers together. A factory keeps construction in one place and makes testing trivial (each test can build a fresh app).
app/main.py
from fastapi import FastAPI
from app.routers import documents, chat
def create_app() -> FastAPI:
app = FastAPI(title="DocChat")
app.include_router(documents.router)
app.include_router(chat.router)
return app
app = create_app()
PHP bridge: APIRouter ≈ a route group in your old framework (Route::prefix('documents')->group(...)). The prefix and tags are applied to every route inside, so you don't repeat /documents on each one.
FastAPI is async-native, but async def is not free magic — it helps only for I/O-bound work: waiting on a database, an HTTP call, or an LLM. While one request is await-ing the network, the event loop serves others. That's the entire benefit.
# GOOD: awaiting I/O lets the loop serve other requests meanwhile @router.post("/ask") async def ask(question: str): answer = await llm_client.complete(question) # network wait return {"answer": answer}
async def, never call a blocking function (a slow CPU loop, time.sleep, a sync DB driver). It freezes the entire event loop — every other request stalls. If a library isn't async-aware, either use a plain def endpoint (FastAPI runs it in a threadpool for you) or offload with run_in_threadpool.
Rule of thumb: async def when you'll await something; plain def when the work is synchronous and FastAPI should manage the thread. For DocChat, the LLM and any async DB driver calls are exactly where async pays off.
You'll build DocChat's API on localhost:8000 and your Next.js frontend on localhost:3000. The first time React calls the API, the browser blocks it with a CORS error — and nothing is actually wrong with your code.
Why: browsers enforce the same-origin policy. A page served from localhost:3000 is a different origin than an API on localhost:8000 (different port = different origin). The browser refuses to expose the response unless the API explicitly says "I allow that origin" via CORS response headers. Your server must opt in.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # the Next.js dev origin
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
curl and Postman ignore it entirely — only browsers enforce it. So if your API "works in Postman but not from React," the answer is almost always missing CORS middleware. Saying this cleanly signals real full-stack experience.Access-Control-Allow-Origin headers by hand in PHP. CORSMiddleware just sets those headers (and handles the preflight OPTIONS request) for you.
BackgroundTasksMiddleware wraps every request: code runs before the endpoint, then after it, on the way out. CORSMiddleware is one example; you can write your own for logging or timing.
@app.middleware("http") async def add_timing(request, call_next): import time start = time.perf_counter() response = await call_next(request) # run the endpoint response.headers["X-Time-Ms"] = str(time.perf_counter() - start) return response
BackgroundTasks let you return a response now and run slow work after. Perfect for DocChat: accept an uploaded document, reply "received" instantly, and process it (chunk + embed) in the background.
from fastapi import BackgroundTasks
def process_document(doc_id: int):
... # chunk, embed, store — the slow part
@router.post("/documents")
def upload(doc_id: int, tasks: BackgroundTasks):
tasks.add_task(process_document, doc_id)
return {"status": "processing"} # returns immediately
BackgroundTasks runs in the same process — great for quick jobs. For heavy, retryable, or long pipelines you'd graduate to a real queue (Celery, RQ, or a managed worker). For DocChat's scope, background tasks are the right tool.
Hardcoding an API key or database URL is the fastest way to leak a secret to GitHub. The 2026-standard answer is pydantic-settings: a BaseSettings class that reads values from environment variables (and a local .env file), validated and typed.
pip install pydantic-settings
app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
openai_api_key: str
cors_origins: str = "http://localhost:3000"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings() # reads env / .env at startup, validates types
Now secrets live in .env (which is .gitignored) and config is one import away. Combine it with DI — a get_settings dependency — and your endpoints stay clean and testable.
.env, with real secrets injected by the host (Render, Railway, Docker) at deploy time. Mentioning that BaseSettings validates the config at startup — so a missing key fails fast instead of at 3am — is the senior touch.TestClient + pytestFastAPI ships a TestClient that calls your app in-process — no running server, no real network. Paired with pytest, you get fast, real HTTP-level tests.
pip install pytest httpx
tests/test_documents.py
from fastapi.testclient import TestClient
from app.main import create_app
client = TestClient(create_app())
def test_list_documents_returns_200():
response = client.get("/documents/")
assert response.status_code == 200
assert isinstance(response.json(), list)
Run it with pytest. Because create_app() is a factory, each test can build a fresh app and override dependencies — e.g. swap get_db for an in-memory database — keeping tests isolated and fast.
TestClient ≈ Laravel's $this->get('/route')->assertStatus(200). Same idea: drive the app through HTTP without booting a server.
APIRouter, add CORSMiddleware for http://localhost:3000 so your Next.js frontend can call it, and write one passing TestClient test. This is the exact structure you'll deploy in the capstone.
app/routers/documents.py
from fastapi import APIRouter, BackgroundTasks, Depends from app.db import get_db router = APIRouter(prefix="/documents", tags=["documents"]) def process_document(doc_id: int): ... # chunk + embed later @router.get("/") def list_docs(db = Depends(get_db)): return db.query(Document).all() @router.post("/") def upload(title: str, tasks: BackgroundTasks, db = Depends(get_db)): doc = Document(title=title) db.add(doc); db.commit() tasks.add_task(process_document, doc.id) return {"id": doc.id, "status": "processing"}
app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import documents
def create_app() -> FastAPI:
app = FastAPI(title="DocChat")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(documents.router)
return app
app = create_app()
tests/test_documents.py
from fastapi.testclient import TestClient
from app.main import create_app
client = TestClient(create_app())
def test_list_documents_returns_200():
response = client.get("/documents/")
assert response.status_code == 200
Run uvicorn app.main:app --reload, hit /documents/, then pytest. When the test goes green and the React origin is allowed, DocChat is production-shaped.
Answer from memory — retrieval is what turns "I read it" into "I know it".
What does a Depends dependency mainly give you?
In a yield dependency, code after yield runs:
Why does the React frontend need CORS enabled?
When does async def actually help performance?
Where should an LLM API key be stored?
Depends, yield dependencies, routers, middleware, and testing. Read it alongside this lesson; the framework docs are unusually good.