Node.js backend patterns: layered architecture, TypeScript, validation, error handling, security, observability, logging, metrics, deployment. Use when build...
---
name: ia-nodejs-backend
class: language
description: >-
Node.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.
paths: "**/*.ts,**/*.js,**/*.mjs,**/*.cjs"
---
# Node.js Backend
**Verify 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.
## Framework Selection
| 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.
## Architecture
```
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
```
- Routes never contain business logic
- Services never import Request/Response
- Repositories never throw HTTP errors
- Dependencies point inward only (Clean Architecture rule): routes -> services -> repositories. Never the reverse.
- For scripts/prototypes: single file is fine -- ask "will this grow?"
## TypeScript Rules
- Use `import type { }` for type-only imports -- eliminates runtime overhead
- Prefer `interface` for object shapes (2-5x faster type resolution than intersections)
- Prefer `unknown` over `any` -- forces explicit narrowing
- Use `z.infer<typeof Schema>` as single source of truth -- never duplicate types and schemas
- Minimize `as` assertions -- use type guards instead
- Add explicit return types to exported functions (faster declaration emit)
- Untyped package? `declare module 'pkg' { const v: unknown; export default v; }` in `types/ambient.d.ts`
## Validation
**Zod** (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.
## Error Handling
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 statusCode
- Unknown → log full stack, return 500 + generic message in production
- Async wrapper: `const 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
## API Design
**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.
- **Hyrum's Law awareness**: every observable response field, ordering, or timing becomes a dependency for callers. Use Zod schemas or Fastify response schemas to control exactly what's serialized -- never return raw ORM objects or untyped objects from handlers.
- **Addition over modification**: add new optional fields rather than changing or removing existing ones. Removing a field from a response schema breaks callers silently. Deprecate first (mark in OpenAPI spec), remove in a later version.
- **Consistent error envelope**: all errors -- validation, auth, not-found, application -- must produce the same `{ error: { code, message, details? } }` structure. Centralize in the error handler middleware. Callers build error handling once; inconsistent errors force per-endpoint special cases.
- **Boundary validation**: validate at the middleware/route handler level (Zod `.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.
- **Third-party responses are untrusted data**: validate shape and content of external API responses before using them in logic, rendering, or decision-making. A compromised or misbehaving service can return unexpected types, malicious content, or missing fields. Parse through a Zod schema before use.
- **Resources**: plural nouns (`/users`), max 2 nesting levels (`/users/:id/orders`)
- **Methods**: GET read | POST create | PUT replace | PATCH partial | DELETE remove
- **Versioning**: URL path `/api/v1/`
- **Response**: `{ data, pagination?: { page, limit, total, totalPages } }`
- **Queries**: `?page=1&limit=20&status=active&sort=createdAt,desc`
- Return `Location` header on 201. Use 204 for successful DELETE with no body.
## Async Patterns
| 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.
## Production Resilience
- **Fail-fast env validation**: parse and validate all environment variables at startup with a Zod schema (`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 endpoints**: expose both `/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.
- **Caching**: Redis cache-aside for DB/API responses; in-memory LRU with TTL for hot paths. Always invalidate on writes.
- **Load shedding**: `@fastify/under-pressure` (or equivalent) -- monitor event loop delay, heap, RSS; return 503 when thresholds exceeded.
- **Response schemas**: In Fastify, always define response schemas -- enables `fast-json-stringify` for 2-3x faster serialization.
- **Circuit breaker**: use `opossum` for outbound service calls. States: CLOSED (normal) -> OPEN (failing, return fallback) -> HALF_OPEN (probe). Prevents cascade failures when downstream services are down.
## Observability
- **Define "working" before instrumenting**: write the questions an on-call engineer will ask when this is broken at 3am ("which dependency is timing out?", "is it all users or one tenant?"), then add only the telemetry that answers them. Instrumentation with no question behind it is cost and noise.
- **Pick the signal by the question it answers**: logs = "what happened in this one case?" (high-detail, structured, sampled under load); metrics = "how often / how fast / how saturated?" (cheap aggregates — keep label cardinality bounded, never user IDs or request IDs as labels); traces = "where did the time or the error go across services?".
- **Structured logging**: `pino` with a stable set of event names and a correlation/request ID propagated through async context (`AsyncLocalStorage`). Never `console.log` in production paths.
- **Metrics**: `prom-client` for RED per route — Rate (request count), Errors (error count), Duration (latency histogram). OpenTelemetry Node SDK for distributed traces across services.
- **Initialize tracing before app imports, then verify it fires**: the OTel SDK must start before the modules it instruments are required, or auto-instrumentation silently no-ops. Before relying on any signal, force an error and send test traffic in staging and confirm the log/metric/trace actually lands — untested instrumentation fails silent.
- **Alert on symptoms, not causes**: page on user-visible symptoms (error-rate spike, latency SLO burn, `/ready` flapping), not on causes (CPU high, heap growing). A cause with no symptom is a dashboard, not a page.
## Discipline
- Simplicity first -- every change as simple as possible, impact minimal code
- Only touch what's necessary -- avoid introducing unrelated changes
- No hacky workarounds -- if a fix feels wrong, step back and implement the clean solution
- Before adding a new abstraction, verify it appears in 3+ places. If not, inline it.
- If a fix requires bypassing TypeScript (`as any`, non-null assertions on untrusted data, `// @ts-ignore`), treat it as a design smell and find the typed solution
## Verify
- `tsc --noEmit` passes with zero errors
- `npm test` passes with zero failures
- No TypeScript bypasses (`as any`, `@ts-ignore`) in new code
## References
- [TypeScript config](./references/typescript-config.md) -- tsconfig, ESM, branded types, compiler performance
- [Security](./references/security.md) -- JWT, password hashing, rate limiting, OWASP
- [API design patterns](./references/api-design.md) -- pagination, filtering, sorting, deprecation
- [Database & production](./references/database-production.md) -- connection pooling, transactions, Docker, logging
don't have the plugin yet? install it then click "run inline in claude" again.
build production-grade Node.js backends with clean layered architecture, type safety, validation at boundaries, centralized error handling, and resilience patterns. use this skill when designing REST APIs, Express/Fastify/Hono/NestJS/Koa servers, tRPC procedures, serverless functions, or any server-side TypeScript code. applies to new builds and refactoring legacy backends.
const type parameters, const assertions)query-docs context because training data may lag releases| deployment | framework | tradeoff |
|---|---|---|
| edge/serverless | Hono | zero dependencies, fastest cold starts, minimal bundle |
| performance API | Fastify | higher throughput than Express, built-in schema validation, lower latency |
| enterprise/structured | NestJS | dependency injection, decorator conventions, team discipline, learning curve |
| legacy/ecosystem | Express | widest middleware ecosystem, longest history, slowest throughput |
| lightweight | Koa | minimal core, composable middleware, smaller than Express |
input: ask user for deployment target, cold start budget, team experience, constraints in existing codebase.
output: chosen framework name, rationale documented in project README.
create this folder layout:
src/
├── routes/ # HTTP handlers only: parse request, call service, format response
├── middleware/ # Auth, validation, rate limiting, logging, error catching
├── services/ # Business logic, orchestration (zero HTTP types here)
├── repositories/ # Data access, queries, ORM calls (no HTTP errors)
├── types/ # Shared TypeScript interfaces, constants, enums
├── config/ # Environment, connection pools, global setup
└── errors/ # Custom error classes (AppError subclasses)
enforce dependency direction: routes -> services -> repositories. never import upward (repositories importing routes is a code smell).
input: folder structure plan, existing layout constraints.
output: directory tree created, verified with find src -type d | sort.
input: tsconfig.json.
set compiler options:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
}
}
rules:
import type { } for type-only imports (zero runtime cost).interface for object shapes (faster type resolution than intersection types).unknown instead of any -- forces explicit type narrowing.z.infer<typeof schema> (single source of truth).as type assertions; use type guards and conditionals instead.declare module 'pkg' { ... } in types/ambient.d.ts.output: tsc --noEmit passes with zero errors and warnings.
use Zod (TypeScript inference first) or TypeBox (Fastify native, better performance). validate only at boundaries: HTTP request entry, before database mutations, environment vars at startup.
example (Express + Zod):
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
app.post('/users', (req, res, next) => {
try {
const payload = createUserSchema.parse(req.body);
const user = await userService.create(payload);
res.status(201).json({ data: user });
} catch (err) {
next(err);
}
});
compose schemas with .extend(), .pick(), .omit(), .partial(), .merge() to avoid duplication.
input: request body shape, query params, path params, response shape.
output: validated payload, strict type inference (CreateUserInput matches schema exactly).
create base class in errors/AppError.ts:
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public isOperational: boolean = true,
public details?: unknown
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: unknown) {
super(message, 400, true, details);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 404, true);
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = 'Unauthorized') {
super(message, 401, true);
}
}
export class ForbiddenError extends AppError {
constructor(message: string = 'Forbidden') {
super(message, 403, true);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, true);
}
}
output: error classes available for import in services and repositories.
place at the end of middleware stack:
// Express
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
code: err.statusCode,
message: err.message,
...(err.details && { details: err.details }),
},
});
}
// Unknown error: log full stack, return generic 500
console.error('Unhandled error:', err);
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({
error: {
code: 500,
message: 'Internal server error',
},
});
}
res.status(500).json({
error: {
code: 500,
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
},
});
});
wrap async route handlers to auto-catch:
const asyncHandler = (fn: any) => (req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User not found');
res.json({ data: user });
}));
output: all errors caught, logged, and returned with consistent { error: { code, message, details? } } envelope.
input: resource names, operations, response shapes.
before writing handler code, define route schema (Zod, Fastify JSON Schema, or OpenAPI spec):
export const listUsersSchema = {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['active', 'inactive']).optional(),
sort: z.string().optional(), // 'createdAt,desc'
}),
response: z.object({
data: z.array(userResponseSchema),
pagination: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
totalPages: z.number(),
}),
}),
};
follow conventions:
/users, /orders, /posts./users/:userId/orders/:orderId.Location header), 204 (no content on DELETE), 400 (bad input), 401 (no auth), 403 (forbidden), 404 (not found), 409 (conflict), 422 (business rule violation), 429 (rate limited), 500 (server fault).{ data, pagination?: { page, limit, total, totalPages } }.?page=1&limit=20&status=active&sort=createdAt,desc./api/v1/resource.never return raw ORM objects or untyped data from handlers; serialize through a Zod schema to enforce Hyrum's Law (all observable fields become caller dependencies):
const userResponseSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
createdAt: z.string().datetime(),
// omit password, internal fields
});
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User not found');
const serialized = userResponseSchema.parse(user);
res.json({ data: serialized });
}));
output: routes defined in routes/ with explicit schemas, handlers delegating to services, response validation.
validate:
do NOT validate inside repositories or services (already validated at boundary).
example:
// config/env.ts
const envSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
});
export const env = envSchema.parse(process.env);
if (!env) throw new Error('Invalid environment');
output: validated payloads at routes, services assume valid input, env crash on startup if invalid.
| pattern | use case | example |
|---|---|---|
async/await |
sequential operations | fetch user, then fetch orders |
Promise.all() |
parallel independent tasks | fetch user + fetch posts + fetch comments in parallel |
Promise.allSettled() |
parallel, some may fail | call 3 external APIs, ignore failures |
Promise.race() |
timeout or first-wins | fetch from cache, race against DB query, timeout if >1s |
never use sync methods in production (readFileSync, execSync, require() dynamic modules). use fs.promises, streams, or worker threads instead.
offload CPU-bound work to worker threads (Piscina or Node.js Worker API).
input: operation type, dependencies, timeout budget.
output: non-blocking code, event loop not blocked.
fail-fast environment validation (step 8 above): parse env vars at startup, crash if invalid.
health endpoints:
// shallow health (process alive?)
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// deep readiness (dependencies reachable?)
app.get('/ready', asyncHandler(async (req, res) => {
await db.query('SELECT 1');
await redis.ping();
res.json({ status: 'ready' });
}));
load balancers use /ready for traffic routing; monitoring probes /health for liveness. don't conflate them.
caching:
load shedding (Fastify example):
import underPressure from '@fastify/under-pressure';
fastify.register(underPressure, {
maxEventLoopDelay: 1000,
maxHeapUsedBytes: 1024 * 1024 * 1024,
maxRssBytes: 2 * 1024 * 1024 * 1024,
});
// returns 503 when thresholds exceeded
response schemas (Fastify):
fastify.post('/users', {
schema: {
response: {
201: {
type: 'object',
properties: {
data: { $ref: '#/definitions/User' },
},
},
},
},
handler: async (req, res) => { ... }
});
enables fast-json-stringify for 2-3x faster serialization.
circuit breaker (external service calls):
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(async (url: string) => {
return fetch(url).then(r => r.json());
}, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
});
breaker.fallback(() => cachedResponse || defaultValue);
const data = await breaker.fire('https://api.example.com/data');
states: CLOSED (normal) -> OPEN (failing, return fallback) -> HALF_OPEN (test probe). prevents cascade failures.
output: /health and /ready endpoints responding, caching layer operational, load shedding active, circuit breakers protecting external calls.
never trust external service responses. parse through a Zod schema:
const externalUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().optional(),
// omit unknown fields
});
const externalUser = await fetch('https://api.external.com/user/123')
.then(r => r.json());
const validated = externalUserSchema.parse(externalUser);
prevents injection, type confusion, and crashes from misbehaving upstream services.
output: validated data, type-safe usage downstream.
run before commit:
tsc --noEmit # zero TypeScript errors
npm test # all tests passing
npm run lint # no style violations
npm run type-check # strict type safety
do NOT commit code with as any, @ts-ignore, or non-null assertions on untrusted data. treat TypeScript bypasses as design smells; find the typed solution instead.
output: all checks passing, zero warnings, code ready for review.
1. framework choice: if deployment is edge/serverless (Cloudflare Workers, Lambda), choose Hono for minimal cold start. else if team prioritizes API throughput and built-in validation, choose Fastify. else if enterprise structure and DI patterns required, choose NestJS. else choose Express (widest ecosystem, longest history).
2. schema validation library: if using Fastify, TypeBox is native and faster. else if TypeScript inference and developer experience matter, choose Zod. else use built-in JSON.parse with manual type guards (slower, not recommended).
3. error handling strategy: if API has existing error envelope format, match it exactly (consistency for callers). else implement custom AppError hierarchy with subclasses for 400, 401, 403, 404, 409, 422, 429. never return different error shapes from different endpoints.
**4. response serial