name: backend-layering description: Route/service/repository layering rules for this Express backend. Use when adding or refactoring any server route, service, repository, or validator — covers layer responsibilities, error contract, transactions, tenant scoping, and the canonical rehearsals example.
Backend layering: route → service → repository
Every backend resource is split into four files. The canonical example to copy is the rehearsals stack:
server/routes/rehearsals.js— thin HTTP layerserver/services/rehearsalService.js— domain logicserver/repositories/rehearsalRepository.js— SQLserver/validators/rehearsalValidators.js— pure input parsing
Read those files before writing or refactoring backend code; match their style exactly.
Layer responsibilities
Route (server/routes/<resource>.js)
- Parses and validates URL/body ids (
parseIdfrom the resource's validators; a localrequireParam(req, res, name)helper that 400s and returns null). - Calls one service function per handler, passing
pool(or letting the service own the transaction) plusreq.tenantId/req.user.idand the raw body. - Translates results:
if (result.error) return sendError(res, result.error)thenres.json(...)/res.status(201).json(...)/res.status(204).end(). - Fires notification helpers exported by the service after responding (e.g.
notifyRehearsalCreated). - Never contains SQL, business rules, or
try/catchfor DB error codes. - Register new routers in
server/routes/index.js.
Service (server/services/<resource>Service.js)
- All domain logic: validation beyond id parsing, state-transition rules, transactions, idempotency, mapping DB errors (
err.code === '23505'→ 409), composing response payloads (e.g. attaching participants). - Error contract: expected failures return
{ error: { status, body } }(define a sharedNOT_FOUNDconst); success returns a named payload like{ rehearsal }or{}for deletes. Throw only on unexpected errors — the global handler turns those into 500s. - Owns transactions:
pool.connect()/BEGIN/COMMIT/ROLLBACK/release()lives here, passing theclientto repository functions. - Push notifications: export
notifyXxx(tenantId, entity)functions that fire-and-forget (.catch(console.error)); the route decides when to call them so they happen after the HTTP response.
Repository (server/repositories/<resource>Repository.js)
- SQL only — no business decisions, no HTTP statuses, no notifications.
- Every function takes an
executor(pool or transaction client) as the first argument so callers control transactions. - Every query is scoped by
tenant_id— this is the multi-tenant invariant; cross-tenant access must surface as "not found" (return null/false), never leak existence. - Return plain values: a row or null, an array, a boolean for delete/exists, a Map for batched child loads (
loadParticipants(executor, ids, tenantId)pattern). - Dynamic PATCH updates: accept prebuilt
fields/valuesfragments, appendupdated_at = NOW()and the WHERE bindings (seeupdateRehearsalFields).
Validators (server/validators/<resource>Validators.js)
- Pure functions, no DB:
parseId, allowed-valueSets (VALID_STATUSES), normalizers, andbuildXxxUpdateFields(body)that turns an allowed-field whitelist into{ fields, values }SET fragments.
Refactoring an existing fat route
- Existing server tests are the regression suite — find them first (
grep -ril <resource> src/tests/server); this is a behavior-preserving refactor, don't change responses, statuses, or error strings. - Extract in order: validators → repository (mechanical query moves) → service (handler bodies minus HTTP) → rewrite route thin.
- Preserve exact error messages and status codes, including 404-not-403 for cross-tenant.
- Run lint plus only the affected server test files (never the full ~8 min suite).
New behavior
When adding backend behavior (not just refactoring), also add an isolation test in src/tests/server/ proving cross-tenant reads/writes 404 — see CLAUDE.md "Multi-tenant isolation".