Node.js Advanced: Enterprise API Design, Error Handling, and Production Architecture
A Node.js API that works in development and a Node.js API that is production-grade are separated by a significant distance. The gap is not framework knowledge — it is architectural discipline. This guide covers the patterns that matter at scale.
Layered Architecture
The single-file Express app is a tutorial convenience, not a production pattern. Enterprise Node.js services require clear separation of concerns across layers.
src/
routes/ → HTTP route definitions, parameter parsing, response shaping
controllers/ → Request orchestration, calls service layer, handles HTTP concerns
services/ → Business logic, domain operations, external integrations
repositories/ → Data access, database queries, ORM models
middleware/ → Cross-cutting concerns: auth, logging, error handling, rate limiting
utils/ → Pure utility functions (no I/O, no side effects)
config/ → Environment-aware configuration
types/ → TypeScript interfaces and type definitions
The dependency direction is strict: routes → controllers → services → repositories. No layer imports from a layer above it. Services do not import from controllers. Repositories do not call external APIs — that belongs in services or dedicated integration adapters.
Example: Route to Repository
// routes/users.ts
import { Router } from 'express';
import { UserController } from '../controllers/UserController';
const router = Router();
const controller = new UserController();
router.get('/', controller.list);
router.get('/:id', controller.getById);
router.post('/', controller.create);
export default router;
// controllers/UserController.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/UserService';
export class UserController {
private userService = new UserService();
list = async (req: Request, res: Response, next: NextFunction) => {
try {
const users = await this.userService.getAll();
res.json(users);
} catch (err) {
next(err); // Pass to centralized error handler
}
};
getById = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.userService.findById(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
};
}
// services/UserService.ts
import { UserRepository } from '../repositories/UserRepository';
import { NotFoundError } from '../utils/errors';
export class UserService {
private repo = new UserRepository();
async getAll() {
return this.repo.findAll();
}
async findById(id: string) {
const user = await this.repo.findById(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
}
}
Structured Error Handling
Unstructured error handling is the most common source of production incidents in Node.js APIs — 500s with no context, swallowed errors that cause silent failures, and stack traces leaking to clients.
Custom Error Classes
// utils/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number,
public code: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404, 'NOT_FOUND');
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
Centralized Error Middleware
// middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
import { logger } from '../utils/logger';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError && err.isOperational) {
// Known, expected errors — log at warn level, return structured response
logger.warn({
message: err.message,
code: err.code,
statusCode: err.statusCode,
path: req.path,
method: req.method,
});
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
},
});
}
// Unknown errors — log at error level, return generic 500
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}
Register it last in your Express app, after all routes:
app.use(errorHandler);
The isOperational distinction is critical. Operational errors (not found, validation failure, unauthorized) are expected — they are part of normal system operation. Non-operational errors (unhandled promise rejections, type errors, dependency failures) indicate a bug or infrastructure problem and should trigger alerts.
Async Patterns at Scale
Promise.all for Parallel Operations
// Sequential — slow
const user = await userRepo.findById(userId);
const orders = await orderRepo.findByUser(userId);
const preferences = await preferenceRepo.findByUser(userId);
// Parallel — fast
const [user, orders, preferences] = await Promise.all([
userRepo.findById(userId),
orderRepo.findByUser(userId),
preferenceRepo.findByUser(userId),
]);
Use Promise.all whenever operations are independent. Sequential await chains are only appropriate when each operation depends on the result of the previous one.
Promise.allSettled for Fault-Tolerant Aggregation
const results = await Promise.allSettled([
fetchFromServiceA(),
fetchFromServiceB(),
fetchFromServiceC(),
]);
const data = results.map((result) =>
result.status === 'fulfilled' ? result.value : null
);
Promise.all rejects on the first failure. Promise.allSettled waits for all and gives you both fulfilled and rejected results. Use it when partial success is acceptable.
Timeout Wrappers
External service calls without timeouts will hang indefinitely during network issues, exhausting your connection pool.
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
// Usage
const user = await withTimeout(
externalUserService.getById(userId),
3000,
'ExternalUserService.getById'
);
Middleware Design
Middleware in Express is a function with signature (req, res, next). Compose it deliberately.
Request Correlation
Every request should carry a correlation ID — a unique identifier that flows through all log entries for that request and into any downstream service calls.
import { v4 as uuidv4 } from 'uuid';
export function correlationId(req: Request, res: Response, next: NextFunction) {
const id = (req.headers['x-correlation-id'] as string) || uuidv4();
req.correlationId = id;
res.setHeader('x-correlation-id', id);
next();
}
Pass the correlation ID in all outbound HTTP calls to downstream services. When an error occurs, you can trace the entire request path across services using a single ID.
Rate Limiting
import rateLimit from 'express-rate-limit';
export const apiRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many requests, please try again later.',
},
},
});
// Apply globally
app.use('/api', apiRateLimit);
// Or apply stricter limits to sensitive routes
const authRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
app.use('/api/auth', authRateLimit);
Configuration Management
Never hardcode configuration. Never read process.env scattered across your codebase. Centralize configuration into a typed, validated module.
// config/index.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NEW_RELIC_LICENSE_KEY: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:', parsed.error.format());
process.exit(1);
}
export const config = parsed.data;
The application fails fast at startup if required configuration is missing or malformed. This is far better than discovering a missing environment variable hours into a deployment when a specific code path is hit.
Health Check Endpoints
Every production Node.js service needs a health check endpoint for load balancers, container orchestration, and monitoring.
app.get('/health', async (req, res) => {
try {
await db.raw('SELECT 1'); // Verify DB connectivity
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: config.NODE_ENV,
});
} catch (err) {
res.status(503).json({
status: 'unhealthy',
error: err.message,
});
}
});
Return 200 when healthy, 503 when not. Load balancers use the status code — not the body — to make routing decisions.
Production Checklist
Before a Node.js service goes to production:
- Centralized, structured (JSON) logging with correlation IDs
- All async operations have timeouts
- Error middleware distinguishes operational from non-operational errors
- Configuration is validated at startup
- Health check endpoint exists and tests real dependencies
- Rate limiting is applied at the API level
process.on('unhandledRejection')andprocess.on('uncaughtException')are handled with graceful shutdown- Dependency injection is used — not module-level singletons — for testability
- No
console.login production code
Production Node.js is not difficult. It requires the same discipline as any other production system — and the same failure to apply that discipline produces the same class of incidents.