Applies tutor-agent's FastAPI conventions: humble controller routes, Pydantic schemas scoped to route files, Celery over BackgroundTasks for heavy work, module-level service singletons, and router registration in factory.py. Use when writing or editing API routes, request/response schemas, service classes, or any HTTP layer code.
Routes have exactly one job: translate HTTP into a service call and back.
A route does: auth → domain validation → one or more service calls → format response. A route never does: business logic, direct repository access, LLM calls, multi-step orchestration.
If a route is doing orchestration across multiple services, that logic belongs in a workflow service. A second service call is fine; three signals a missing abstraction.
Industry consensus: "Your FastAPI layer should be boring. Painfully boring." — all interesting decisions live in services.
| Thing | Location | Why |
|---|---|---|
Request/response schemas (BaseModel) | In the route file itself | Co-located with the HTTP concern that owns them |
| Domain models | core/entities/ as dataclasses | Not Pydantic — routes call on them |
.to_dict()| Business logic | services/ | Never in routes |
| Service instances | Module-level in services/__init__.py | Singleton, constructor-injected in tests |
The project uses dataclasses in core, not Pydantic. When returning domain objects from routes, call .to_dict() — don't create Pydantic mirrors of domain objects.
BackgroundTasks vs Celery
Use Celery (via apply_async) for anything that touches the DB non-trivially, needs retry, or takes >1 second. FastAPI.BackgroundTasks has no retry, no task state, no transaction support — it's only suitable for fire-and-forget notifications.
Service instantiation
Services are created once at import time as module-level singletons in services/__init__.py. Routes import them directly: from services import auth_service, quiz_service. For testing, construct service with injected mocks — don't patch module globals.
Auth
Always Depends(auth_service.require_auth). Never read tokens manually in a route handler.
core/. core/entities/ is for domain dataclasses only.app/factory.py — it won't be registered otherwise.app/middleware/ and is registered via dedicated functions (e.g., register_cors_middleware(app)), never inlined in create_app().infras/cache/pubsub — not polling.logger.error(..., exc_info=True) including user.id and the resource id for traceability.Use HTTPException with a detail string. The project does not wrap errors in a custom envelope — return the standard FastAPI error shape.
raise HTTPException(status_code=404, detail="Section not found")
raise HTTPException(status_code=403, detail="Forbidden")
raise HTTPException(status_code=400, detail="Invalid selected answer")
Catch service-layer exceptions at the route boundary, map them to HTTP status codes, and re-raise as HTTPException. Don't let domain exceptions leak to the client.
List routes return a plain list or a wrapper dict — they do not paginate unless the collection is unbounded. For bounded collections (a user's subjects, sections per subject), return the full list. Add pagination only when query latency or payload size is demonstrably a problem.
To understand existing patterns before writing new ones, read:
app/api/quiz_routes.py — standard route shape with auth and validationapp/api/subject_routes.py — list/create/delete patternsapp/factory.py — router registrationservices/quiz_service.py — service constructor injection