Engineering standards
Engineering standards
Structure
apps/ deployable things (api, web, mobile, docs)packages/ shared code (shared, db, catalogue, config)tools/ standalone developer tooling, not deployedAn apps/ package may depend on packages/. A packages/ package must never depend on an apps/. Nothing depends on tools/.
Types and schemas
One definition, derived downstream. Per ADR-0008:
packages/db Drizzle schema ← the source of truth ↓ drizzle-zodpackages/shared Zod schemas + types ← what clients import ↓apps/web, apps/mobile, apps/api- Never hand-write a type that
drizzle-zodcan derive. - Never define the same shape twice for web and mobile. That is what
packages/sharedis for, and the contract check (gate step 8) exists to catch it.
Validation at the boundary
Parse untrusted input with Zod once, at the edge — request handlers, form submissions, external API responses. Inside the boundary, work with parsed types and trust them.
Do not validate defensively in the middle of the call stack. If an inner function needs to re-check its inputs, the boundary is in the wrong place.
Errors
- Throw typed errors, not strings.
- An error crossing the API boundary becomes a structured response with a stable code. Clients switch on the code, never on the message.
- Never swallow an error to make a test pass. See ci-and-gates.md.
- Errors reach Sentry. Anything caught and not rethrown must be logged deliberately, with a comment saying why it is safe to continue.
The CPU budget
Workers free tier allows 10ms CPU per invocation, and per ADR-0009 the web app is a Worker too. This is the real ceiling — not the 100K daily request count.
Average Workers use around 2.2ms, but auth validation plus a database query plus serialisation in one request gets close.
Rules:
- No N+1 queries. Ever. One request, a bounded number of queries.
- No heavy serialisation in the request path. Large transforms belong in a background worker or a cached column.
- The feed and discovery queries are the ones at risk — they join across follows and recent activity. Measure them.
- Bundle growth is the leading indicator of CPU growth, which is why gate step 5 checks it.
Rate limiting
Every public endpoint is rate limited. This ships in the first commit of apps/api (WI-012), not later.
Cloudflare has no spend protection by default. An unthrottled endpoint can exhaust the daily quota and force a mid-month upgrade. This is a cost control, not a security nicety.
Data access
- All database access goes through
packages/db. No package builds its own connection. - The connection string comes from the
HYPERDRIVEbinding, never a rawDATABASE_URL— ADR-0004. - Hand-written SQL is expected and fine for FTS and discovery. Use Drizzle’s
sqltemplate so it stays typed.
Domain invariants
These are correctness rules from ADR-0006, not style preferences. Each has a test.
- The shelf rating is derived. It projects from the most recent session rating. Any write path setting it directly is a bug.
- Older session ratings are never overwritten. Opinion changing over time is the product.
- A session participant may have no user account. Every query joining participants tolerates a null
user_id. - Everything on a session is nullable except
game_idandplayed_at— ADR-0007. A change adding a required session field breaks that ADR and needs a superseding one.
Naming
- Files and directories:
kebab-case - TypeScript:
camelCasevalues,PascalCasetypes - Database:
snake_casetables and columns, tables plural (shelf_entries,sessions) - Branches:
wi-###-slugfor work items;chore-<slug>for the rare change that belongs to no work item — backlog bookkeeping, a dependency bump, fixing a stale link
Prefer folding bookkeeping into the next work item’s PR. A steady stream of chore- PRs is a smell: it usually means work is being done outside the backlog, which is exactly what the work-item model exists to prevent. Use chore- when the change genuinely cannot wait, not as a way around writing a spec.
Comments
Comment why, not what. A comment restating the code is noise that goes stale.
The exception worth writing every time: a non-obvious constraint. // Hyperdrive pools this; do not add a pool here earns its place forever.