Node.js backend practices
Guide agents to organize routes, middleware, and domain layers on Express/Fastify/Nest-style stacks, with unified errors, logging, and configuration injection.
This page gives Agents a complete Node.js backend reference: a full Express middleware chain example (auth/logging/rate-limiting/error handling), a unified error middleware, Zod request validation, type-safe environment variable parsing, and graceful shutdown implementation.
The SKILL defines middleware order, auth, and Zod validation failure response format; prohibits logging sensitive fields like password/token; wraps async routes with asyncHandler to prevent unhandled Promise rejections.
- Database: connection pools, transaction boundaries, and how you reference migrations (Prisma/Knex).
- Observability: request id, structured logging (pino), aligned with the observability skill.
- Testing: supertest integration tests + conventions for mocking outbound HTTP (msw/nock).
Request pipeline (middleware → routes)
[ TCP / TLS termination (often at reverse proxy) ]
│
▼
┌─────────────┐ Trust proxy, request id, security headers, CORS, body size / rate limits
│Global middleware│──── Parse cookie/body; do not trust unvalidated bodies before auth
└─────────────┘
│
▼
┌─────────────┐ JWT / session / API key → inject user / tenant context
│ Auth middleware │──── Failure: uniform 401/403 without leaking internals
└─────────────┘
│
▼
┌─────────────┐ Route params + DTO/schema validation; failure: 422/400 with field errors
│Validate & route │──── Handlers receive validated input only
└─────────────┘
│
▼
┌─────────────┐ Domain services / repos; throw mappable business error types
│ Domain & I/O │──── Uncaught → outer error middleware → 5xx + logging
└─────────────┘
│
▼
[ Response serialization ] ──► 404 / global error handler (stack traces dev-only)
Framework names differ (Express chains, Fastify hooks, Nest pipes/guards/interceptors), but the pattern holds: generic work near the edge, auth after body parsing and before business logic, centralized errors last.
Express middleware chain and Zod validation
Full Express middleware chain (with order comments):
// app.ts — Express full middleware configuration
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import pinoHttp from 'pino-http'
import { authenticate } from './middleware/auth'
import { errorHandler } from './middleware/errorHandler'
import { itemsRouter } from './routes/items'
const app = express()
// 1. Trust proxy (required behind reverse proxy)
app.set('trust proxy', 1)
// 2. Security headers (set early)
app.use(helmet())
// 3. CORS (preflight completes before body parsing)
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))
// 4. Request logging + Request ID injection
app.use(pinoHttp({
genReqId: (req) => req.headers['x-request-id'] ?? crypto.randomUUID(),
redact: ['req.headers.authorization', 'req.body.password'],
}))
// 5. Basic rate limiting (before body parsing)
app.use(rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true }))
// 6. Body parsing (size-limited)
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
// 7. Authentication (after body parsing, before routes)
app.use('/api', authenticate)
// 8. Business routes
app.use('/api/v1/items', itemsRouter)
// 9. 404 handler
app.use((_req, res) => res.status(404).json({ type: 'not-found', status: 404 }))
// 10. Global error handler (must be last, 4 parameters)
app.use(errorHandler)
Zod request validation middleware:
// middleware/validate.ts
import { z, ZodSchema } from 'zod'
import { Request, Response, NextFunction } from 'express'
export function validate<T>(schema: ZodSchema<T>, target: 'body' | 'query' | 'params' = 'body') {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req[target])
if (!result.success) {
return res.status(422).json({
type: 'https://api.example.com/problems/validation-error',
title: 'Validation Error',
status: 422,
errors: result.error.issues.map(issue => ({
field: issue.path.join('.'),
code: issue.code,
message: issue.message,
})),
})
}
req[target] = result.data // replace with validated data
next()
}
}
// Usage example
const CreateItemSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
sku: z.string().regex(/^[A-Z]{3}-\d{3}$/),
})
router.post('/', validate(CreateItemSchema), async (req, res, next) => {
// req.body is now CreateItem type, validated by Zod
})
Unified error handling, env vars, and graceful shutdown
Unified error handling middleware:
// middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
export class AppError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly isOperational = true
) { super(message) }
}
// asyncHandler wrapper (avoids try/catch in every route)
export const asyncHandler = (fn: Function) =>
(req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next)
// Centralized error handler (must have 4 parameters)
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
const isProd = process.env.NODE_ENV === 'production'
if (err instanceof AppError && err.isOperational) {
// Known business error → 4xx
return res.status(err.status).json({
type: `https://api.example.com/problems/${err.code}`,
title: err.message,
status: err.status,
requestId: (req as any).id,
})
}
// Unknown error → 5xx (hide details in production)
req.log?.error({ err, reqId: (req as any).id }, 'Unhandled error')
res.status(500).json({
type: 'https://api.example.com/problems/internal-error',
title: 'Internal Server Error',
status: 500,
...(isProd ? {} : { detail: err.message, stack: err.stack }),
})
}
Type-safe environment variable parsing (Zod):
// config/env.ts
import { z } from 'zod'
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
ALLOWED_ORIGINS: z.string().optional(),
})
const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.format())
process.exit(1)
}
export const env = parsed.data
Graceful Shutdown:
// server.ts
const server = app.listen(env.PORT, () => {
console.log(`Server listening on port ${env.PORT}`)
})
async function gracefulShutdown(signal: string) {
console.log(`${signal} received. Starting graceful shutdown...`)
// 1. Stop accepting new connections
server.close(async (err) => {
if (err) console.error('Error closing server:', err)
// 2. Close database connection pool
await db.$disconnect()
// 3. Close Redis connection
await redis.quit()
console.log('Graceful shutdown complete')
process.exit(err ? 1 : 0)
})
// 4. Force exit on timeout (avoid hanging)
setTimeout(() => {
console.error('Shutdown timeout, forcing exit')
process.exit(1)
}, 30_000)
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
Align with the observability skill: the same request id should flow through access logs, business logs, and error reports to trace 4xx/5xx end to end.
Middleware order note generator
Check the capabilities your service actually uses to generate a numbered snippet you can paste into the SKILL “middleware order” section; ordering follows common Express/Fastify guidance—adjust if your stack differs.
With nothing checked, a placeholder is emitted; select all for the full reference chain. Nest users can map entries to Middleware → Guard → Pipe → Interceptor → Filter.
---
name: node-backend-practices
description: Layered Node HTTP service with a clear error model
---
# Rules
- Middleware order: trust proxy → helmet → CORS → logger+reqId → rate limit → body parse → auth → routes → 404 → errorHandler
- Async routes must use asyncHandler wrapper, or async/await + try/catch + next(err)
- Error tiers: AppError (business/4xx) vs unknown errors (5xx), unified mapping in errorHandler
- Request validation: Zod safeParse, 422 response includes errors array (field/code/message)
- Env vars: Zod EnvSchema validated at startup; missing/malformed fields call process.exit(1)
- Logging: pino structured logging, redact password/token/Authorization
- Graceful shutdown: SIGTERM → server.close() → db.$disconnect() → 30s timeout force exit
# Steps
1. Create config/env.ts: Zod parses process.env, exports env object
2. Create middleware/errorHandler.ts: AppError class + asyncHandler + errorHandler
3. Create app.ts: mount middleware chain in order
4. Use validate(Schema) middleware in routes to validate body/query/params
5. Business logic throws new AppError(status, code, message)
6. In server.ts listen for SIGTERM/SIGINT, implement gracefulShutdown
7. Integration tests: supertest(app) + beforeAll/afterAll manage DB connection