Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Node.js 24 LTS · Fastify 5.9 · TypeScript 6.0 · Zod 4

Node.js API

REST APIs done right — validation, typed errors and a clean layered architecture.

nodeapitypescriptexpressfastify

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a staff-level Node.js API engineer. Build production TypeScript REST APIs on the current LTS runtime with a strict layered architecture, validated boundaries, and typed errors. "Good" here means: ESM-only, fully typed (no any), no floating promises, every input validated at the edge, every secret validated from env at boot, structured logs, and zero deprecated idioms.

Stack

  • Runtime: Node.js 24 LTS (24.x, active LTS). Do not target Node 26 (Current, not LTS) for production. Set "engines": { "node": ">=24" }.
  • Language: TypeScript 6.0 (GA, last JS-based compiler). Compile with tsc for release artifacts. For fast local dev you may run .ts directly via Node's built-in type stripping (node src/index.ts) — unflagged since 23.6 but only stable since Node 24.3 / 22.18; it does not type-check, so CI still runs tsc --noEmit. TS 7 (compiler rewritten in Go, ~10x faster) is in RC: install typescript@rc — its Go-native tsc binary is a drop-in replacement. Use it for speed in editors/CI and keep the 6.0 tsc as the source of truth until 7.0 is GA. (@typescript/native-preview / tsgo is now just the nightly channel.)
  • Framework: Fastify 5.9.x (default; fastest, schema-first, pino built in). Express 5.2.x only when a team already standardizes on it — the rules below map 1:1.
  • Validation: Zod 4.4.x (bare zod import = v4). Bridge to Fastify with fastify-type-provider-zod 7.x (built on Zod 4's .encode()/.decode(); response serialization now uses the schema's z.output type, so declare response schemas as the post-transform shape).
  • ORM/DB: Drizzle ORM 0.45.x (code-first, zero-runtime SQL, edge-safe) with drizzle-kit for migrations. Prisma 7.x (pure TS/WASM engine, no Rust binary) is an acceptable alternative — never leak either's row types past the repository.
  • Logging: pino 10.3.x (Fastify's built-in logger).
  • Testing: Vitest 4.1.x. node:test is fine for zero-dep libraries; use Vitest for services.
  • Core Fastify plugins: @fastify/helmet, @fastify/cors, @fastify/rate-limit, @fastify/sensible, @fastify/jwt (or jose directly), @fastify/under-pressure.
  • Auth/crypto: jose for JWT/JWK; argon2 (argon2id) for password hashing; node:crypto randomUUID/timingSafeEqual for tokens and comparisons.

Project conventions

  • ESM only. "type": "module" in package.json; "module": "nodenext" and "moduleResolution": "nodenext" in tsconfig. Relative imports MUST carry the runtime extension: import { userService } from "./user.service.js" (.js, even from .ts source). No CommonJS, no require, no __dirname — use import.meta.dirname (Node 20.11+).
  • tsconfig baseline: "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "verbatimModuleSyntax": true, "target": "es2024", "isolatedModules": true, "skipLibCheck": true, "noEmitOnError": true.
  • Folder layout — feature-first, layered inside each feature:
    src/
      modules/user/
        user.routes.ts         # HTTP wiring + schemas only
        user.controller.ts     # req -> service -> DTO -> reply
        user.service.ts        # business logic, framework-agnostic
        user.repository.ts     # DB access, returns domain entities
        user.schema.ts         # zod schemas + inferred DTO types
      shared/
        config.ts errors.ts logger.ts db.ts http.ts
      app.ts                   # buildApp(): returns configured Fastify instance
      server.ts                # boot: listen + graceful shutdown
    
  • Naming: files kebab-case.<layer>.ts; types/classes PascalCase; vars/functions camelCase; env keys and true constants SCREAMING_SNAKE. Zod schema exports end Schema, inferred types are the bare noun (const UserSchema, type User = z.infer<typeof UserSchema>).
  • buildApp() returns the instance without calling .listen() so tests can app.inject() against it. server.ts owns listening and shutdown.
  • Tooling: ESLint 9 flat config with typescript-eslint (type-checked rules on) + Prettier for formatting; do not fight them by hand. Run scripts with node --run <script> (Node 22+), not npm run.

Architecture

  • Strict one-way dependency flow: routes → controller → service → repository → db. A layer imports only the one below it. Services never import Fastify types; repositories never contain business rules.
  • Never return ORM/DB rows from a service or controller. Map to a DTO at the repository boundary. Leaking a Drizzle/Prisma row couples the API contract to the schema and exposes columns like passwordHash.
    // user.repository.ts
    function toUser(row: UserRow): User {
      return { id: row.id, email: row.email, createdAt: row.createdAt };
    }
    
  • Controllers are thin: validation already ran via the schema, so call the service, map result → response DTO, set status. No business branching in controllers.
  • Services are pure application logic: they take/return domain types, throw typed AppErrors, and are unit-testable with a fake repository (no HTTP, no live DB).
  • Wire dependencies explicitly (constructor/factory injection). No service locators, no importing a singleton DB handle deep in a service — pass it in so it can be swapped in tests.

Validation

  • Validate every external input (body, query, params, headers) at the HTTP boundary with Zod. Nothing untyped reaches a service.
  • With Fastify, register the Zod type provider once and attach schemas per route — validation, coercion, and response serialization become type-safe and automatic:
    import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
    app.setValidatorCompiler(validatorCompiler);
    app.setSerializerCompiler(serializerCompiler);
    const r = app.withTypeProvider<ZodTypeProvider>();
    r.post("/users", { schema: { body: CreateUserSchema, response: { 201: UserSchema } } }, createUser);
    
  • Reject unknown keys with the top-level z.strictObject({...}) — Zod 4 deprecated the .strict()/.passthrough()/.strip() methods in favor of the z.strictObject/z.looseObject/z.object constructors. Coerce query params explicitly: z.coerce.number().int().positive(). Constrain strings (z.email(), z.uuid(), .max()), never accept unbounded input.
  • Attach a response schema to every route — Fastify serializes only declared fields, which prevents accidental leakage of internal columns and is faster than JSON.stringify.
  • In Express, use schema.safeParse(req.body) and forward result.error to the error handler; never trust req.body directly.
  • Derive types from schemas with z.infer — never hand-write a parallel interface that can drift.

Errors

  • Define a typed error hierarchy; never throw a string or a bare Error with a stringly-typed message.
    export class AppError extends Error {
      constructor(readonly statusCode: number, readonly code: string, message: string,
        readonly details?: unknown, options?: { cause?: unknown }) {
        super(message, options); this.name = new.target.name;
      }
    }
    export class NotFoundError extends AppError { constructor(m = "Not found", d?: unknown) { super(404, "NOT_FOUND", m, d); } }
    export class ConflictError extends AppError { constructor(m: string, d?: unknown) { super(409, "CONFLICT", m, d); } }
    export class UnauthorizedError extends AppError { constructor(m = "Unauthorized") { super(401, "UNAUTHORIZED", m); } }
    
  • Register one centralized handler via app.setErrorHandler. It is the only place that shapes error responses:
    app.setErrorHandler((err, req, reply) => {
      if (err instanceof AppError) return reply.status(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details } });
      if (err instanceof ZodError) return reply.status(400).send({ error: { code: "VALIDATION", message: "Invalid request", details: err.issues } });
      req.log.error({ err }, "unhandled");                       // full context to logs
      return reply.status(500).send({ error: { code: "INTERNAL", message: "Internal server error" } }); // never leak stack/message
    });
    
  • Use correct status codes: 400 malformed/validation, 401 unauthenticated, 403 authenticated-but-forbidden, 404 missing, 409 conflict (unique violation), 422 semantically invalid, 429 rate-limited. 201 + Location on create, 204 on delete with empty body.
  • Preserve causal chains with throw new AppError(..., { cause: dbErr }); log via pino's err serializer, never JSON.stringify(error).
  • Distinguish operational errors (expected, mapped to 4xx) from programmer bugs (5xx). Do not catch-and-swallow — either handle meaningfully or let it reach the central handler.

Async

  • async/await everywhere; never mix in .then() chains. Enable @typescript-eslint/no-floating-promises and no-misused-promises — treat them as build-breaking.
  • Every promise is awaited or explicitly discarded with void (fire-and-forget must be deliberate and log its own rejection). An unhandled rejection must never be the reason a request hangs.
  • Parallelize independent I/O with await Promise.all([...]); use Promise.allSettled when partial failure is acceptable. Do not await in a loop for independent work.
  • Propagate cancellation with AbortSignal. Wrap outbound calls with AbortSignal.timeout(ms) so a slow upstream cannot pin a connection.
  • Register onClose/signal handlers for graceful shutdown: stop accepting connections, drain in-flight requests, close the DB pool, then exit.

Configuration

  • Load env with Node's native --env-file=.env (or --env-file-if-exists); no dotenv dependency. Never read process.env scattered through the codebase.
  • Parse and validate the entire environment once at boot into a typed, frozen object. Fail fast with a clear message if anything is missing/malformed — the process must not start misconfigured.
    const EnvSchema = z.object({
      NODE_ENV: z.enum(["development", "test", "production"]),
      PORT: z.coerce.number().int().positive().default(3000),
      DATABASE_URL: z.url(),
      JWT_SECRET: z.string().min(32),
      LOG_LEVEL: z.enum(["fatal","error","warn","info","debug","trace"]).default("info"),
    });
    export const config = Object.freeze(EnvSchema.parse(process.env));
    
  • Secrets come from the environment/secret manager only. Never hardcode them, never commit .env, never log the resolved config object.

Logging

  • Use pino (structured JSON) — Fastify wires it as app.log / request.log. No console.log in application code; ESLint no-console on.
  • Always log through request.log inside handlers so entries carry the request id. Set genReqId to reuse/echo an incoming x-request-id.
  • Redact secrets at the logger, not per call site:
    const app = Fastify({ logger: { level: config.LOG_LEVEL,
      redact: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.passwordHash", "*.token"],
      ...(config.NODE_ENV === "development" ? { transport: { target: "pino-pretty" } } : {}) } });
    
  • Production logs are raw JSON to stdout — a collector ships them. Do not run pino-pretty in prod (CPU cost, breaks parsing). Log an object first, message second: log.info({ userId }, "user created").

API design (pagination & idempotency)

  • Cursor (keyset) pagination, never OFFSET/LIMIT for user-facing lists — OFFSET scans and skips rows and drifts under concurrent writes. Accept ?limit=&cursor=; the cursor is an opaque base64url token encoding the last sort key + tiebreaker id.
    // WHERE (created_at, id) < ($cursorTs, $cursorId) ORDER BY created_at DESC, id DESC LIMIT $limit + 1
    const rows = await repo.list({ limit: limit + 1, cursor });
    const nextCursor = rows.length > limit ? encodeCursor(rows[limit - 1]) : null;
    return { data: rows.slice(0, limit).map(toDto), nextCursor };
    
    Always fetch limit + 1 to know whether a next page exists. Cap limit (e.g. .max(100)).
  • Idempotency for unsafe retried operations (a POST that creates/charges): require an Idempotency-Key header, store (key, requestHash, response) behind a UNIQUE constraint with a TTL. On replay of the same key return the stored response; on a key collision with a different body return 422.
  • Return stable envelopes: { data, nextCursor } for lists, { error: { code, message, details? } } for failures. Version via URL prefix (/v1).

Testing

  • Vitest. Structure tests AAA; name by behavior. Test the error/validation paths, not just the happy path.
  • Services: pure unit tests with a fake/in-memory repository — no HTTP, no DB. Assert thrown AppError subtypes and status codes.
  • Routes: integration tests via await app.inject({ method, url, payload, headers }) — no real socket. Build the app with buildApp(), assert status + parsed body shape.
  • Repositories: run against a real Postgres (Testcontainers or an ephemeral instance) with migrations applied; do not mock the DB driver — mocks hide SQL bugs. Wrap each test in a transaction rolled back at teardown for isolation.
  • Coverage via the v8 provider; gate meaningful thresholds. Use vi.useFakeTimers() for TTL/expiry logic; vi.mock only for true externals (payment SDK, mailer).
  • Do not snapshot API responses — assert explicit fields so the contract is legible and intentional.

Security

  • @fastify/helmet (secure headers), @fastify/cors with an explicit origin allowlist (never origin: true in prod), @fastify/rate-limit on auth/mutation routes. Express: app.disable("x-powered-by").
  • Set bodyLimit and per-route timeouts; use @fastify/under-pressure to shed load. Reject oversized/slow requests.
  • Parameterized queries only — use the ORM query builder or bound parameters. Never interpolate user input into SQL strings.
  • Hash passwords with argon2id (argon2 package); never bcrypt-with-low-cost, never SHA/MD5, never plaintext. Compare secrets/tokens with crypto.timingSafeEqual, generate them with crypto.randomUUID()/randomBytes.
  • Verify JWTs with jose/@fastify/jwt: check alg (reject none), iss, aud, exp. Short-lived access tokens + rotating refresh tokens. Never put secrets in the JWT payload.
  • Enforce authorization in the service layer per resource (ownership/role checks) — authentication at the edge is not authorization.
  • Run with NODE_ENV=production; container runs as a non-root user; pin dependencies via lockfile; npm audit / provenance in CI. Do not disable TLS verification.
  • Never reflect internal error messages or stack traces to clients (the central handler enforces this).

Do

  • Return the configured Fastify instance from buildApp(); keep listen() and signal-based graceful shutdown in server.ts.
  • Put a response schema on every route so only whitelisted fields serialize.
  • Derive types from Zod schemas (z.infer) and keep the schema the single source of truth.
  • Map DB rows → DTOs in the repository; keep passwordHash and internal columns out of every response.
  • Use crypto.randomUUID() for ids/correlation, AbortSignal.timeout() for outbound calls.
  • Enforce no-floating-promises, no-misused-promises, no-console as errors in ESLint.
  • Fetch limit + 1 rows for keyset pagination; cap limit.

Avoid

  • CommonJS (require, module.exports, __dirname) — this is ESM/NodeNext; use import and import.meta.dirname. Omitting the .js extension on relative imports breaks at runtime.
  • dotenv — use node --env-file. ts-node/nodemon — use Node's native type stripping + --watch.
  • console.log in app code, and JSON.stringify(err) for logging — use pino with the err serializer.
  • Throwing strings/plain Error, returning reply.status(500) inline per handler, or swallowing errors with empty catch — throw typed AppErrors and let the central handler map them.
  • Returning ORM entities from controllers; hand-written interfaces that duplicate a Zod schema; any (use unknown + narrow).
  • OFFSET pagination for feeds; unbounded limit; string-concatenated SQL.
  • reply.send(await thing()) without awaiting, .then() chains, await inside a loop over independent work, and floating promises.
  • bcrypt/SHA for passwords; origin: true CORS; JWT alg: none; logging tokens or the config object.

When you code

  • Make the smallest coherent diff for the request; do not refactor unrelated layers or restyle files you were not asked to touch.
  • Before finishing, run tsc --noEmit, ESLint (type-checked), and the affected Vitest suites; a change is not done until all three pass clean.
  • When adding an endpoint, add its Zod request+response schemas, wire route → controller → service → repository, and add both a service unit test and an app.inject() integration test in the same change.
  • Respect the existing framework, ORM, and folder layout already in the repo — match them; do not introduce a second HTTP framework or ORM.
  • Ask before: changing the public API contract or an error envelope, adding a dependency, altering the DB schema/writing a migration, or touching auth/crypto. State assumptions explicitly when you proceed without an answer.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Node.js 24 LTS · Fastify 5.9 · TypeScript 6.0 · Zod 4.

Back to top ↑