Coding conventions observed in this codebase. Follow these when contributing.
src/for source,test/for tests — test files mirrorsrc/layout- One export per file where practical (
app.ts→buildApp,errors.ts→AppError) src/server.tsis the composition root — the only place env vars are read- Routes live in
src/routes/, registered inbuildApp()viaapp.register()
Use type imports for type-only imports:
import type { FastifyPluginAsync } from "fastify";Use .js extensions in all import paths (required by NodeNext module resolution):
import { buildApp } from "../src/app.js";Order: Node builtins → third-party → local, separated by blank lines:
import { randomUUID } from "node:crypto";
import Fastify from "fastify";
import { AppError } from "./lib/errors.js";- Strict mode is on —
strict: true,noUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride - Prefer
unknownoverany— the codebase has zeroanyusage - Use
readonlyon constructor parameters and properties that should not be reassigned:constructor( private readonly baseUrl: string, private readonly token: string ) {}
- Use
interfacefor object shapes that may be extended,typefor unions and utility types:export interface AppOptions { logLevel?: string; }
- Prefix unused parameters with
_:app.setNotFoundHandler(async (_request, reply) => { ... });
- Use
asyncconsistently on route handlers and hooks, even for synchronous returns — Fastify expects this - Arrow functions for inline handlers and callbacks
- Named function declarations for top-level exports and test helpers:
export function buildApp(opts: AppOptions = {}) { ... }
Use AppError for expected failure cases:
throw new AppError("PAYMENT_REQUIRED", "Please pay up", 402, { amount: 9.99 });The error handler in app.ts maps errors to the standard envelope:
AppError→ itsstatusCode,code,message,details- Unknown
Error→ 500 withINTERNAL_SERVER_ERRORcode and generic message - Unknown routes → 404 with
NOT_FOUNDcode
Error codes are UPPER_SNAKE_CASE strings:
NOT_FOUND, INTERNAL_SERVER_ERROR, VALIDATION_ERROR
Success:
{ "data": { "status": "ok" } }Error:
{
"error": { "code": "NOT_FOUND", "message": "Route not found", "details": {} }
}Always include details (default to {} if none).
- Zod schemas for environment validation (
src/config/env.ts) - Defaults for optional values (
PORT,HOST,LOG_LEVEL) — required values have no defaults z.coercefor values that arrive as strings fromprocess.env:PORT: z.coerce.number().int().positive().default(3001);
- Constructor injection everywhere except
server.ts:const client = new CoreApiClient(env.CORE_API_BASE_URL, env.CORE_API_TOKEN);
Framework: Vitest with explicit imports (no globals):
import { describe, expect, it } from "vitest";Test structure:
describeblocks group by feature or componentitdescriptions read as sentences:"returns 200 with ok status","rejects empty CORE_API_TOKEN"- No nesting of
describeblocks
Route tests use app.inject() (Fastify's built-in HTTP injection):
const app = buildTestApp();
const response = await app.inject({ method: "GET", url: "/healthz" });
expect(response.statusCode).toBe(200);Mocking:
- Use
vi.stubGlobal("fetch", ...)for outbound HTTP mocks - Restore mocks in
beforeEachwithvi.restoreAllMocks() - Keep mocks minimal — only mock what the test needs
Test helper pattern — custom app builders for specialized tests:
function buildErrorTestApp() {
const app = buildBaseApp();
app.get("/throw-generic", async () => {
throw new Error("boom");
});
return app;
}- Prettier handles all formatting — do not override with manual style choices
- Double quotes for strings (Prettier default)
- Semicolons always
- Trailing commas in multi-line structures
- Run
npm run formatbefore committing