Architecture¶
The backend is a synchronous FastAPI application over PostgreSQL, organised in clear layers, with an in-process event broadcaster bolted onto the database commit lifecycle for real-time sync.
The layers¶
Source lives under backend/app/:
app/
├── main.py App assembly: lifespan, telemetry, CORS, routers, health
├── config.py Settings (pydantic BaseSettings) — env-driven config
├── api/v1/
│ ├── routers/ HTTP handlers: tasks, projects, dependencies, users, events, undo
│ ├── schemas/ Pydantic request/response models
│ └── audit.py Audit-log writes + SSE event builders
├── models/ SQLAlchemy ORM: task, project, dependency, user, …
├── services/ Business logic extracted from routers (e.g. start-date derivation)
├── db/session.py The get_db() request-scoped session dependency
├── auth/ Provider abstraction + per-project membership/roles
└── events/ SSE broadcaster, publisher (after_commit listener), payloads
Each layer has one job:
- Routers handle HTTP: parse and validate input, enforce authorization, call into services and models, record the audit entry, and serialise the response. They're thin.
- Schemas are Pydantic models. Request schemas (
TaskCreate,TaskUpdate) validate input; response schemas (TaskRead) serialise ORM rows directly viafrom_attributes=True. - Models are the SQLAlchemy ORM — tables, columns, relationships, enums. See Data model.
- Services hold logic that's too involved for a router and is reused — for example deriving a task's effective start date, or diffing an assignee set.
- Auth resolves the current user and their role on a project. See Authentication & authorization.
- Events turn committed writes into live SSE messages. See Real-time sync.
Synchronous by design¶
Every endpoint is a plain def, not async def. This is a deliberate choice (ADR 0009): synchronous SQLAlchemy is simpler to reason about and test, and FastAPI runs sync handlers on a threadpool, which is more than adequate at this scale.
The one exception is the SSE streaming endpoint in app/api/v1/routers/events.py, which must be async def to hold a long-lived connection. That single async handler is the seam the whole real-time system is built around.
The request-scoped session¶
All database access goes through one dependency, get_db() in app/db/session.py:
def get_db() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
session.commit() # commit on success
except Exception:
session.rollback() # roll back on any error
raise
finally:
session.close()
One session lives for one request. The handler does its work inside an open transaction; on a clean return the session commits, and that commit is what fires the real-time events (see below). On any exception it rolls back — and crucially, the queued events are dropped too, so a rolled-back write never reaches a subscriber.
A write, end to end¶
Here's the full path of a PATCH to a task — the busiest write in the system. It touches almost every layer, so it's the best single example to learn from.
PATCH /api/v1/projects/{project_id}/tasks/{task_id}
│
├─ require_project_role("editor") ── auth: resolve user + role, or 404/403
│ └─ returns ProjectAccess(user, project_id, role)
│
├─ patch_task() handler ── app/api/v1/routers/tasks.py
│ ├─ fetch the task, 404 if not in this project
│ ├─ validate: assignees are members, start ≤ due, no parent cycle
│ ├─ optimistic-concurrency check (if_unchanged_since) → maybe 409
│ ├─ apply column updates
│ ├─ task.updated_at = clock_timestamp() ── wall-clock, see Concurrency
│ ├─ validate dependency invariants → maybe 422
│ ├─ apply the assignee set (join-table diff)
│ ├─ audit.record_update() ── write AuditLog row + build SSE event
│ │ └─ queue_event(): event appended to session.info["pending_events"]
│ └─ cascade-shift dependents if the due date moved
│
├─ serialise_task() → TaskRead ── response
│
└─ session.commit() [end of get_db scope]
└─ after_commit listener fires (on the worker thread)
└─ broadcaster.publish_threadsafe(pending_events)
└─ loop.call_soon_threadsafe(...) ── hop to the event loop
└─ fan out to each subscriber whose project matches
The key idea: the audit log and the live event stream are the same write. When a handler records an audit entry, it also builds the SSE payload, stores it on the audit row, and queues a copy. On commit, the queued copies fan out live; on reconnect, the stored copies replay. Live and catch-up are byte-identical because they're the same payload. This is covered in depth under Real-time sync.
Health endpoints¶
Two unauthenticated probes, both in app/main.py:
GET /health— liveness plus the live Alembic revision (read straight fromalembic_version). The deploy pipeline compares this against the revision the code expects, to block shipping code ahead of its migration. See Migrations.GET /health/data— a data-path smoke test that runs the same task-serialisation code an ordinary list request uses, so a green result means the app can actually serve tasks end to end, not just respond to a ping.
Where to go next¶
- Data model — what's actually stored.
- API surface — the endpoints and conventions.
- Real-time sync — the broadcaster and the commit hook.