Install
openclaw skills install @iliaal/compound-eng-nodejs-backendNode.js backend patterns: layered architecture, TypeScript, validation, error handling, security, observability, logging, metrics, deployment. Use when building REST APIs, REST endpoints, middleware, Express/Fastify/Hono/NestJS/Koa servers, tRPC procedures, Bun servers, or server-side TypeScript.
openclaw skills install @iliaal/compound-eng-nodejs-backendVerify before implementing: For framework-specific APIs (Express 5, Fastify 5, Node.js 22+ built-ins), look up current docs via Context7 (query-docs) before writing code. Training data may lag current releases.
| Context | Choose | Why |
|---|---|---|
| Edge/Serverless | Hono | Zero-dep, fastest cold starts |
| Performance API | Fastify | Higher throughput than Express, built-in schema validation |
| Enterprise/team | NestJS | DI, decorators, structured conventions |
| Legacy/ecosystem | Express | Most middleware, widest adoption |
Ask user: deployment target, cold start needs, team experience, existing codebase.
src/
├── routes/ # HTTP: parse request, call service, format response
├── middleware/ # Auth, validation, rate limiting, logging
├── services/ # Business logic (no HTTP types)
├── repositories/ # Data access only (queries, ORM)
├── config/ # Env, DB pool, constants
└── types/ # Shared TypeScript interfaces
import type { } for type-only imports -- eliminates runtime overheadinterface for object shapes (2-5x faster type resolution than intersections)unknown over any -- forces explicit narrowingz.infer<typeof Schema> as single source of truth -- never duplicate types and schemasas assertions -- use type guards insteaddeclare module 'pkg' { const v: unknown; export default v; } in types/ambient.d.tsZod (TypeScript inference) or TypeBox (Fastify native). Validate at boundaries only: request entry, before DB ops, env vars at startup. Use .extend(), .pick(), .omit(), .partial(), .merge() for DRY schemas.
z.coerce.boolean() is Boolean(v). Every non-empty string is truthy, so the literal strings "false", "0", "no" and "off" all coerce to true; only "" and a real boolean false yield false. Clients and LLM callers routinely emit booleans as JSON strings, and the advertised schema saying type: boolean does not stop a host that forwards arguments unvalidated. The damage concentrates exactly where it is worst: a default-true flag can be forced on but never string-off, and a destructive flag (kill_existing, force, active) passed "false" fires. Use plain z.boolean() where fail-loud is acceptable, or z.preprocess the known spellings before z.boolean() so unrecognized strings still reject rather than silently becoming true. .optional() short-circuits undefined before the preprocess, so optional params still default correctly, and JSON Schema generation still emits { type: "boolean" }.z.record(valueType) -- it requires z.record(keyType, valueType), e.g. z.record(z.string(), z.number()). TypeScript rejects the single-arg form immediately (tsc: Expected 2-3 arguments, but got 1). If the type error is suppressed, the lone argument becomes the KEY schema and valueType stays undefined, so the first .parse() on a non-empty object throws TypeError: Cannot read properties of undefined (reading '_zod') — a raw TypeError, not a Zod validation error.Custom error hierarchy: AppError(message, statusCode, isOperational) → ValidationError(400), NotFoundError(404), UnauthorizedError(401), ForbiddenError(403), ConflictError(409)
Centralized handler middleware:
AppError → return { error: message } with statusCodeconst asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);Codes: 400 bad input | 401 no auth | 403 no permission | 404 missing | 409 conflict | 422 business rule | 429 rate limited | 500 server fault
Contract-first: define route schemas (Zod schemas, Fastify JSON Schema, or OpenAPI spec) before writing handler logic. The schema is the contract -- implementation follows. Generate OpenAPI/Swagger docs from these schemas for interactive API documentation.
{ error: { code, message, details? } } structure. Centralize in the error handler middleware. Callers build error handling once; inconsistent errors force per-endpoint special cases..parse() on request body/params, Fastify schema validation). Services and repositories trust that input was validated at entry -- no redundant checks scattered through business logic./users), max 2 nesting levels (/users/:id/orders)/api/v1/{ data, pagination?: { page, limit, total, totalPages } }?page=1&limit=20&status=active&sort=createdAt,descLocation header on 201. Use 204 for successful DELETE with no body.| Pattern | Use When |
|---|---|
async/await | Sequential operations |
Promise.all | Parallel independent ops |
Promise.allSettled | Parallel, some may fail |
Promise.race | Timeout or first-wins |
Never use readFileSync or other sync methods in production -- use fs.promises or stream equivalents. Offload CPU work to worker threads (Piscina). Stream large payloads.
const env = envSchema.parse(process.env)). If invalid, crash before serving traffic. Never discover a missing env var on the first request that needs it./health (shallow, always 200 if process is alive) and /ready (deep, verifies database, cache, and critical dependencies are reachable). Load balancers probe /ready for traffic routing; monitoring probes /health for process liveness. Don't conflate them.@fastify/under-pressure (or equivalent) -- monitor event loop delay, heap, RSS; return 503 when thresholds exceeded.fast-json-stringify for 2-3x faster serialization.opossum for outbound service calls. States: CLOSED (normal) -> OPEN (failing, return fallback) -> HALF_OPEN (probe). Prevents cascade failures when downstream services are down. When the outbound call is the security decision (authz check, trust score, license or entitlement gate), the fallback must be deny, and any fail-open allowance scopes to transport failure only -- connection refused, DNS failure, timeout. A response that arrived but cannot be trusted (4xx/5xx, malformed JSON, schema-invalid body, unknown verdict value) stays blocked: the endpoint was reached and did not answer. Absence of evidence is not evidence of trust. Same for "no history yet" states -- reject by default, allow only through an explicit onboarding opt-in.fetch (undici) drops long-silent responses. A request that returns zero bytes for tens of seconds -- a reasoning LLM call, a slow report generator, a buffering gateway -- fails as Invalid response body ... Premature close whenever the egress path reaps idle TCP flows (cloud NAT, stateful firewall). curl and Node's built-in https module survive the identical request on the same box because they keep the flow warm. Rule out the red herrings before redesigning: it fails on the first call of a fresh process (not pool reuse), at concurrency 1 (not concurrency), and with stream: true yielding zero chunks (streaming does not help when the upstream buffers before its first byte). An SDK's httpAgent/https.Agent option is silently ignored once the SDK is on global fetch. Route that one request over Node's built-in https module with req.on('socket', s => s.setKeepAlive(true, 10_000)) and an explicit req.setTimeout(...), keeping the request/response contract identical. It works on a laptop and fails only on the deployed box -- reproduce on the host that fails.attempt N failed hides the one string ("Premature close" vs "401" vs "timeout") that names the failure class.page.goto(url).catch(() => null) inside a scraper loop parses whatever is still loaded -- the previous item's DOM -- and writes the extraction under the current item's cache key. Nothing throws, extraction "succeeds", and with a TTL the poisoned row outlives the blip that caused it; a first-item failure caches the landing page as data. Keep the .catch for uniform timeout handling but gate the parse and the cache write on post-conditions that confirm the right resource is loaded: the resolved URL contains the item's own path segment (compare case-insensitively -- redirects normalize slug case), and a selector present on every valid target page is in the result (this catches the URL-preserving cases: interstitials, soft-404s, layout changes). Throw on either miss so the existing per-item catch drives retry or skip, and the cache write is unreachable.pino with a stable set of event names and a correlation/request ID propagated through async context (AsyncLocalStorage). Never console.log in production paths.prom-client for RED per route — Rate (request count), Errors (error count), Duration (latency histogram). OpenTelemetry Node SDK for distributed traces across services./ready flapping), not on causes (CPU high, heap growing). A cause with no symptom is a dashboard, not a page.as any, non-null assertions on untrusted data, // @ts-ignore), treat it as a design smell and find the typed solutiontsc --noEmit passes with zero errorsnpm test passes with zero failuresas any, @ts-ignore) in new code