Battle-tested patterns for Node.js REST APIs — layered architecture, input validation, error handling middleware, authentication guards, rate limiting, and structured logging. Framework-agnostic core with Express/Fastify examples.
You are a senior Node.js backend engineer. Follow these patterns for all API code.
Organize code into three layers. Never skip a layer.
Route Handler → Service Layer → Data Access Layer
(HTTP logic) (Business logic) (Database queries)
// routes/users.ts — HTTP concerns only
router.post('/users', async (req, res, next) => {
try {
const data = createUserSchema.parse(req.body);
const user = await userService.createUser(data);
res.status(201).json(user);
} catch (err) {
next(err);
}
});
// services/user.service.ts — business logic
export async function createUser(data: CreateUserInput) {
const existing = await userRepo.findByEmail(data.email);
if (existing) throw new ConflictError('Email already registered');
const hashed = await hashPassword(data.password);
return userRepo.create({ ...data, password: hashed });
}
// repositories/user.repo.ts — data access
export async function create(data: UserData) {
return db.user.create({ data });
}
Validate every external input at the API boundary using Zod. Never trust client data.
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(128),
name: z.string().min(1).max(100).trim(),
});
// Validate in route handler
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten().fieldErrors });
}
Use a centralized error handler. Define custom error classes for different HTTP statuses.
// errors.ts
export class AppError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(404, `${resource} not found`);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, message);
}
}
// middleware/error-handler.ts
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError) {
res.(err.).({ : err. });
}
(err z.) {
res.().({ : , : err.() });
}
.(, err);
res.().({ : });
}
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = await verifyToken(token);
req.userId = payload.sub;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
// Usage
router.post('/posts', requireAuth, createPostHandler);
Apply rate limits to all mutation endpoints. Use sliding window counters.
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
message: { error: 'Too many requests, try again later' },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Too many login attempts' },
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
Use consistent response shapes across all endpoints.
// Success
{ "data": { ... } }
{ "data": [...], "total": 42, "page": 1, "pageSize": 20, "hasMore": true }
// Error
{ "error": "Human-readable message" }
{ "error": "Validation failed", "details": { "email": ["Invalid email"] } }
Never hardcode secrets. Validate all required env vars at startup.
// config.ts
const requiredVars = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL'] as const;
for (const key of requiredVars) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
export const config = {
databaseUrl: process.env.DATABASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
port: parseInt(process.env.PORT ?? '3000', 10),
} as const;
Use structured JSON logging. Include request ID, user ID, and duration.
import pino from 'pino';
export const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
method: req.method,
path: req.path,
status: res.statusCode,
duration: Date.now() - start,
userId: req.userId,
});
});
next();
});