lilith-platform.live/codebase/@features/api/ONBOARDING.md

3.3 KiB

quinn.api — Contributor Guide

Where does X go?

Trigger Layer Path
New DB table / domain noun entities/<noun>/ types, schema, repo, barrel
CRUD endpoint for one entity surfaces/<surface>/<noun>.ts import from entity barrel
Logic touching 2+ entities features/<verb>/ e.g. tour-planning, client-intake
Auth, CORS, rate-limit, error shape app/middleware/ applied in app/server.ts
DB connection, migrations runner shared/db/ no other layer owns this
HTTP error helpers, fetch utils shared/http/
Mail, iCal, TOTP primitives shared/mail/, shared/ical/, shared/totp/

Decision tree:

New work incoming
  ├─ Single noun, single table?      → entities/<noun>/
  ├─ Route exposing entity CRUD?     → surfaces/<surface>/<noun>.ts
  ├─ Orchestrates multiple entities? → features/<verb>/
  └─ Framework primitive?            → shared/<concern>/

How to add an entity

bun run scaffold:entity <name>   # kebab-case, e.g. financial-record

This generates four files under src/entities/<name>/:

  • types.ts — domain interfaces and const arrays
  • schema.ts<name>Migrations: readonly Migration[]
  • repo.tslist/get/create/update/delete functions (no ORM)
  • index.ts — barrel re-export

After scaffolding:

  1. Replace the placeholder types with your real domain model.
  2. Add <name>Migrations to the runMigrations(...) call in src/app/server.ts.
  3. Mount the entity in a surface (see next section).

How to add a surface route

// src/surfaces/my/<entity>.ts
import { Hono } from 'hono';
import { listFoos, createFoo } from '@/entities/foo';
import { getDb } from '@/shared/db';

export const foosRouter = new Hono()
  .get('/', (c) => c.json(listFoos(getDb())))
  .post('/', async (c) => {
    const body = draftSchema.parse(await c.req.json());
    return c.json(createFoo(getDb(), body), 201);
  });

Then mount it in src/surfaces/<surface>/index.ts:

export const mySurface = new Hono()
  .route('/foos', foosRouter);

Surfaces are thin. Keep each file under 80 lines. Validation lives in the surface; business logic lives in features/; DB access lives in entities/.

Dependency rules

shared imports nothing outside itself. entities imports shared and other entities (via barrel only — never reach into another entity's internals). features imports entities and shared, never other features or surfaces. surfaces imports features, entities, and shared, never another surface. app imports everything. These rules are enforced by dependency-cruiser in CI — a failing lint blocks merge.

Running tests

bun test                         # all tests
bun test src/__tests__/server    # specific file

Tests use an in-memory SQLite via createTestApp() from src/__tests__/harness.ts. No real files are touched. Each test file calls createTestApp() in beforeEach and ctx.close() in afterEach to reset state between tests.

Proposing a dependency-cruiser exception

If a boundary rule feels wrong for a legitimate reason, open a PR editing .dependency-cruiser.cjs with an inline comment explaining why the boundary should bend. Three or more exceptions filed in a quarter is a signal to revisit the rules rather than accumulate exceptions.