Backend (FastAPI)¶
How the backend is structured and how to add new features.
Layered structure¶
Each module follows the same layered pattern:
modules/projects/
├── models.py # SQLAlchemy models — DB layer
├── schemas.py # Pydantic schemas — validation/serialization
├── service.py # Business logic — no FastAPI imports here
├── router.py # FastAPI routes — thin, validation only
└── tests/
Rule: routers don't contain business logic. They validate input, call a service, return output. Business logic lives in services.
Dependency injection¶
FastAPI's Depends is how we wire things together. Common dependencies:
from fastapi import Depends
from src.core.deps import get_db, get_current_user, require_permission
@router.post("/projects")
async def create_project(
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
_: None = Depends(require_permission("projects:write")),
):
return await projects_service.create(db, user, payload)
Tenant context¶
Tenant is set by middleware and accessible via request.state.tenant. Don't accept tenant from request body or query params — never trust the client.
# Good — tenant comes from authenticated context
project = await projects_service.create(db, current_tenant, payload)
# Bad — never accept tenant_id from the client
project = await projects_service.create(db, payload.tenant_id, payload)
Database access¶
All DB access via SQLAlchemy async. Use the session from Depends(get_db).
from sqlalchemy import select
stmt = select(Project).where(Project.id == project_id)
result = await db.execute(stmt)
project = result.scalar_one_or_none()
The session's search_path is set to the tenant's schema by middleware. Every query is automatically scoped.
Error handling¶
Use the custom exceptions in src.core.exceptions:
from src.core.exceptions import NotFoundError, PermissionDeniedError
if not project:
raise NotFoundError(f"Project {project_id} not found")
These are converted to proper HTTP responses by the global exception handler.