Skip to content

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 via from_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 from alembic_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