Skip to main content
The backend (sportz) is a single Node process. It is intentionally a monolith (REST and WebSocket in one deployable) because at current scale that is simpler and cheaper than splitting, and splitting too early is a classic over-engineering trap (see Project Status).

Folder structure

Why app.ts is separate from index.ts

createApp() builds the Express app but does not call listen(). index.ts imports it, creates the HTTP server, attaches the WebSocket layer, and only then listens. This split exists for testability. Supertest drives an Express app object directly, in-memory, without binding a port. If app construction and server.listen() lived in the same file, importing it from a test would start a real server as a side effect, and two test files would fight over the port. Separating “build the app” from “run the app” is what makes the entire route layer testable. (This was a real refactor; see Issues.)

Request lifecycle

Note the order: security first, then validation, then persistence, then broadcast. A request that fails Arcjet never touches Zod; one that fails Zod never touches the database. Each layer is a gate.

The database layer and its SSL strategy

db.ts connects via a pg Pool wrapped by Drizzle. The non-obvious part is SSL, which has three distinct cases: a subtlety that caused a real bug (Issues):
The trap: { rejectUnauthorized: false } still attempts SSL. Turning SSL off entirely requires ssl: false. Those look similar and behave completely differently.

Live scores

PATCH /matches/:id/score updates the row and calls broadcastScoreUpdate(match), which emits score_update to all connected clients, not the match’s room, because the score is shown on every client’s grid card (ADR-010, Real-Time). Same injected-seam pattern as the other broadcasts: the route calls res.app.locals.broadcastScoreUpdate, wired in index.ts.

Demo mode

When DEMO_MODE=true (set in render.yaml), index.ts starts an in-process simulator (src/demo/) so the deployed app is always live for visitors. It keeps a few live matches across sports (per-sport playbooks with real clubs/players and rule-correct scoring), and on an interval writes commentary/score updates directly via Drizzle and calls the broadcast functions directly, with no HTTP hop, so no Arcjet to fight (a trusted first-party producer). Each event type has several message templates and the simulator avoids repeating the exact previous line (pickMessage), so distinct events don’t read as identical. Match lifecycle (full-time → fresh fixture) keeps scores realistic; pruning bounds the DB. It’s a deliberate demo shortcut; a real pipeline would decouple ingestion (ADR-011).

Observability wiring

The New Relic agent must load before any other module patches express/pg, which ESM’s import-hoisting defeats if done naively inside index.ts; see ADR-013 for why src/bootstrap.ts exists as a separate, import-free entry point that conditionally loads newrelic first, then hands off to index.ts. package.json and the Dockerfile both point at bootstrap.ts/bootstrap.js now, not index.ts/index.js. src/posthog.ts follows the same env-gate shape as src/arcjet.ts (posthogKey ? new PostHog(...) : null), so every posthog?.capture(...) call site is a no-op with no key set. Full detail in Observability.

Failure modes