How the Backend Is Built
Published Jul 4, 2026
Every backend eventually faces the same fork in the road: do you let request handlers grow into monoliths that talk to the database, run business rules, and shape HTTP responses all at once — or do you draw hard lines between those jobs? Praxis924's backend picks the lines. Every request flows through three layers with one responsibility each: a thin router, a service that owns the logic, and a repository that owns the database. Here's how it's built, and why the discipline pays off.
The problem
The naive FastAPI app puts everything in the route function. The handler reads the request, runs a database query, applies business rules, catches errors, and builds the JSON — all in one place. It works for the first ten endpoints. Then the same "does this lesson exist?" check gets copy-pasted into six handlers, each subtly different. A rule change means hunting through routers. Testing a business rule means spinning up an HTTP client and a database. Error handling drifts, so one endpoint returns {"detail": "..."} and another returns a raw 500 with a stack trace.
The root issue is that HTTP concerns, domain logic, and persistence have different rates of change and different test needs — but they're tangled into one function. Untangling them is the whole design.
The design
Three layers, strict direction of dependency:
- Routers (
api/v1/) parse the request, enforce auth via dependencies, and return a Pydantic model. No SQL, no business rules. - Services (
services/) hold the logic — orchestrating steps, enforcing invariants, calling the LLM layer or other services. - Repositories (
repositories/) are the only code that touches SQLAlchemy. Everything above them speaks in domain objects, not queries.
Pydantic schemas guard the edges. A request body is validated into a typed model before the handler runs; the response is declared with response_model=, so FastAPI both serializes and documents it. Invalid input never reaches your logic.
Here's a thin router delegating cleanly. Notice how little it does:
1router = APIRouter()
2
3@router.post("/lessons/{lesson_id}/complete",
4 response_model=LessonProgressOut)
5def set_complete(
6 lesson_id: UUID,
7 payload: CompleteRequest, # validated by Pydantic
8 db: Session = Depends(get_db), # injected per-request
9 user: User = Depends(get_current_user), # auth dependency
10):
11 _require_lesson(db, lesson_id) # FK guard -> clean 404
12 return progress_service.complete(db, user.id, lesson_id, payload)The handler reads like a table of contents: validate, authorize, delegate, return. The interesting part — how completion interacts with challenge status, streaks, and achievement triggers — lives in the service, where it can be unit-tested without an HTTP request.
Errors become HTTP status codes in one place. Services raise domain exceptions; a single registered handler maps them to responses. The router never writes a status code by hand:
1class AppException(Exception):
2 def __init__(self, code, message, status_code=400): ...
3
4class ResourceNotFoundError(AppException): # -> 404
5 def __init__(self, msg="Requested resource not found"):
6 super().__init__("RESOURCE_NOT_FOUND", msg, 404)
7
8class ServiceUnavailableError(AppException): # -> 503, safe to retry
9 ...
10
11@app.exception_handler(AppException)
12async def handle(request, exc):
13 return JSONResponse(exc.status_code,
14 {"error": {"code": exc.code, "message": exc.message}})Now raise ResourceNotFoundError("Lesson not found") anywhere in the stack produces a consistent 404 with a stable error code the frontend can branch on. When the LLM provider is quota-exhausted, ServiceUnavailableError yields a 503 that signals "safe to retry — nothing was persisted," which is exactly the contract the durable generation workflow needs.
Auth is a dependency, not a decorator sprinkled through logic. Depends(get_current_user) resolves the JWT; admin-only routes add require_admin. Because it's a FastAPI dependency, it runs before the handler body and is trivially overridden in tests.
The gotchas
| Trap | Fix |
|---|---|
| Writing a progress row against a deleted lesson | FK guard queries the lesson first; a missing row raises ResourceNotFoundError (clean 404) instead of an IntegrityError at commit (ugly 500) |
| Adding a new table | Just define the model and import it in main.py — Base.metadata.create_all auto-creates it on startup |
| Altering an existing table (add/drop column, change enum) | create_all won't touch it — generate an Alembic migration and upgrade head |
| SQL leaking into a router | It belongs in a repository; routers and services stay query-free |
| Inconsistent error bodies | Never build a status code in a handler — raise a domain exception and let the single handler shape it |
Warning: create_all creates missing tables but never alters existing ones. Add a column to a model without an Alembic migration and it silently won't exist in the database — the app boots, then blows up on first write. CI runs a migration-drift check precisely to fail that PR before it ships.
Build it yourself
The minimal version is four files:
core/exceptions.py— anAppExceptionbase plus a few subclasses carryingstatus_code, and one@app.exception_handlerthat renders them.repositories/thing_repository.py— functions taking aSessionand returning models. The only placedb.query(...)appears.services/thing_service.py— functions taking aSessionplus domain args, calling repositories, raising domain exceptions.api/v1/thing.py— anAPIRouterwhose handlers declare aresponse_model, takeDepends(get_db)andDepends(get_current_user), and call the service.
Wire the router into main.py, import every model there so create_all sees it, and register the exception handlers. That's a testable backend where each layer is mockable in isolation.
What changes at scale
The layering holds; the surrounding machinery grows. Introduce a unit-of-work so a service can span multiple repository writes in one transaction with a single commit/rollback boundary. Add read replicas behind the repository so read-heavy endpoints (the dashboard, search) don't compete with writes. Push slow work — lesson generation already does this via DBOS durable workflows — fully out of the request path so routers stay fast. Layer in a Redis cache at the service boundary (Praxis924 caches LLM responses there). And as the schema evolves, Alembic migrations become the audit trail of every structural change — which is exactly why create_all is confined to greenfield tables and never trusted to alter one.