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 arraysschema.ts—<name>Migrations: readonly Migration[]repo.ts—list/get/create/update/deletefunctions (no ORM)index.ts— barrel re-export
After scaffolding:
- Replace the placeholder types with your real domain model.
- Add
<name>Migrationsto therunMigrations(...)call insrc/app/server.ts. - 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.