failure modes. Notice the deliberate separation of concerns: each subclass defines its HTTP status, machine-readable code, and optional payload extensions.
// src/errors/DomainErrors.ts
import { BaseApiError } from './BaseApiError';
export class ResourceMissingError extends BaseApiError {
constructor(resource: string, identifier: string) {
super(`${resource} not found`, 404, 'RESOURCE_MISSING');
this.resource = resource;
this.identifier = identifier;
}
public readonly resource: string;
public readonly identifier: string;
}
export class ValidationFailure extends BaseApiError {
constructor(public readonly violations: Array<{ field: string; reason: string }>) {
super('Request validation failed', 422, 'VALIDATION_FAILURE');
}
}
export class AuthenticationRequired extends BaseApiError {
constructor() {
super('Valid credentials required', 401, 'AUTH_REQUIRED');
}
}
export class RateLimitExceeded extends BaseApiError {
constructor(public readonly retryAfterSeconds: number) {
super('Request quota exceeded', 429, 'RATE_LIMITED');
}
}
Architecture Rationale: Using a strict hierarchy prevents error sprawl. The isOperational flag enables the middleware to apply different logging and response strategies. Serialization is decoupled from the constructor to keep error instantiation lightweight and consistent across environments.
Phase 2: Route-Level Safeguards
Express-style frameworks require explicit error forwarding. Wrapping async handlers eliminates repetitive try/catch blocks while guaranteeing that unhandled rejections bubble to the central dispatcher.
// src/middleware/routeGuard.ts
import { Request, Response, NextFunction } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
export function routeGuard(handler: AsyncHandler) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
Input validation should occur before business logic executes. A schema-driven middleware enforces contracts early, reducing cognitive load in controllers.
// src/middleware/schemaEnforcer.ts
import { Request, Response, NextFunction } from 'express';
import { ValidationFailure } from '../errors/DomainErrors';
type RuleSet = Record<string, Array<{
required?: boolean;
type?: 'string' | 'number' | 'email';
minLength?: number;
maxLength?: number;
pattern?: RegExp;
enum?: Array<string | number>;
}>>;
export function schemaEnforcer(schema: RuleSet) {
return (req: Request, _res: Response, next: NextFunction) => {
const payload = { ...req.body, ...req.query, ...req.params };
const violations: Array<{ field: string; reason: string }> = [];
for (const [field, rules] of Object.entries(schema)) {
const value = payload[field];
for (const rule of rules) {
if (rule.required && (value === undefined || value === null)) {
violations.push({ field, reason: `${field} is required` });
break;
}
if (value === undefined || value === null) continue;
if (rule.type === 'string' && typeof value !== 'string') {
violations.push({ field, reason: `${field} must be a string` });
} else if (rule.type === 'number' && typeof value !== 'number') {
violations.push({ field, reason: `${field} must be a number` });
} else if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value as string)) {
violations.push({ field, reason: `${field} has invalid email format` });
} else if (rule.minLength && typeof value === 'string' && value.length < rule.minLength) {
violations.push({ field, reason: `${field} requires minimum ${rule.minLength} characters` });
} else if (rule.maxLength && typeof value === 'string' && value.length > rule.maxLength) {
violations.push({ field, reason: `${field} exceeds maximum ${rule.maxLength} characters` });
} else if (rule.enum && !rule.enum.includes(value)) {
violations.push({ field, reason: `${field} must be one of: ${rule.enum.join(', ')}` });
} else if (rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) {
violations.push({ field, reason: `${field} format is invalid` });
}
}
}
if (violations.length > 0) {
return next(new ValidationFailure(violations));
}
next();
};
}
Phase 3: Centralized Dispatch Middleware
The error handler acts as the single point of truth for response formatting, logging, and environment-aware sanitization. It must enrich errors with request context, classify them correctly, and prevent information leakage.
// src/middleware/errorDispatcher.ts
import { Request, Response, NextFunction } from 'express';
import { BaseApiError } from '../errors/BaseApiError';
export function errorDispatcher(err: Error, req: Request, res: Response, _next: NextFunction) {
const correlationId = req.headers['x-correlation-id'] as string || req.id || 'unknown';
let statusCode = 500;
let errorCode = 'SYSTEM_FAILURE';
let message = 'An unexpected system failure occurred';
let payload: Record<string, unknown> = {};
if (err instanceof BaseApiError) {
statusCode = err.statusCode;
errorCode = err.errorCode;
message = err.message;
payload = err.serialize();
}
const logContext = {
correlationId,
method: req.method,
path: req.path,
statusCode,
errorCode,
message,
isOperational: err instanceof BaseApiError ? err.isOperational : false,
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined,
};
if (err instanceof BaseApiError && err.isOperational) {
console.info(JSON.stringify({ level: 'warn', ...logContext }));
} else {
console.error(JSON.stringify({ level: 'error', ...logContext }));
if (process.env.NODE_ENV === 'production') {
statusCode = 500;
errorCode = 'SYSTEM_FAILURE';
message = 'An unexpected system failure occurred';
payload = { error: { code: errorCode, message } };
}
}
payload.correlationId = correlationId;
res.status(statusCode).json(payload);
}
Phase 4: Process-Level Resilience
Unhandled promise rejections and uncaught exceptions bypass Express middleware entirely. They must be intercepted at the process level to prevent silent failures or abrupt terminations.
// src/runtime/safeguards.ts
import { gracefulShutdown } from '../utils/lifecycle';
process.on('unhandledRejection', (reason: unknown) => {
console.error(JSON.stringify({
level: 'error',
event: 'UNHANDLED_REJECTION',
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
}));
});
process.on('uncaughtException', (err: Error) => {
console.error(JSON.stringify({
level: 'fatal',
event: 'UNCAUGHT_EXCEPTION',
message: err.message,
stack: err.stack,
}));
gracefulShutdown('UNCAUGHT_EXCEPTION');
});
Architecture Rationale: Separating process-level hooks from middleware ensures that framework-agnostic failures are still captured. The gracefulShutdown utility allows in-flight requests to complete before termination, preventing data corruption during restarts.
Pitfall Guide
1. Swallowing Errors in Async Wrappers
Explanation: Developers sometimes catch errors in route guards and return default values or empty responses instead of forwarding them. This masks failures and breaks observability.
Fix: Always call next(err) or rethrow. The wrapper's sole responsibility is delegation, not resolution.
2. Leaking Stack Traces in Production
Explanation: Returning err.stack or internal variable names in production responses exposes implementation details, aiding attackers and confusing clients.
Fix: Conditionally strip stack traces based on NODE_ENV. Use correlation IDs for support lookup instead of raw internals.
3. Mixing Validation Logic with Business Rules
Explanation: Embedding format checks, length limits, and enum validations inside controllers bloats business logic and duplicates validation across endpoints.
Fix: Extract validation into schema middleware. Controllers should only handle domain operations and delegate to services.
4. Ignoring Unhandled Promise Rejections
Explanation: Node.js defaults to warning on unhandled rejections, but silent failures accumulate. In production, this leads to zombie processes and memory leaks.
Fix: Register a global unhandledRejection listener. Log with full context and consider automated alerting for frequency thresholds.
5. Over-Classifying Errors
Explanation: Creating dozens of custom error classes for minor variations (e.g., UserNotFoundError, ProductNotFoundError, OrderNotFoundError) creates maintenance debt and dilutes the error contract.
Fix: Use generic resource-agnostic classes (ResourceMissingError, ConstraintViolationError) and pass resource metadata as parameters.
6. Failing to Attach Request Context
Explanation: Errors logged without request IDs, user identifiers, or path information require manual log correlation, increasing MTTR by 3-5x.
Fix: Inject correlation IDs early in the middleware chain. Attach them to every error instance before dispatch.
7. Treating All 5xx Errors as Equal
Explanation: Clients retrying on 500 may overwhelm a database during connection pool exhaustion, while 503 indicates temporary unavailability.
Fix: Map specific infrastructure failures to appropriate status codes (502 for gateway, 503 for maintenance, 504 for timeout). Document retry semantics in API contracts.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal microservice communication | Strict typed errors + gRPC/GraphQL error extensions | Low latency, strong contracts, no HTTP overhead | Higher initial dev cost, lower long-term integration debt |
| Public-facing REST API | Structured JSON errors + correlation IDs + rate limit headers | Client-friendly, cacheable, standard HTTP semantics | Moderate dev cost, significantly reduces support tickets |
| Legacy monolith migration | Gradual adoption: wrap existing routes with async guard, incrementally replace inline checks | Minimizes regression risk, allows phased rollout | Low immediate cost, requires disciplined tech debt tracking |
| High-throughput event processing | Fire-and-forget with dead-letter queues + structured error payloads | Prevents pipeline blockage, enables replay without HTTP semantics | Infrastructure cost increases, but system stability improves |
Configuration Template
// src/app/bootstrap.ts
import express from 'express';
import { errorDispatcher } from './middleware/errorDispatcher';
import { routeGuard } from './middleware/routeGuard';
import { schemaEnforcer } from './middleware/schemaEnforcer';
import { ResourceMissingError, ValidationFailure } from './errors/DomainErrors';
const app = express();
app.use(express.json());
// Example route demonstrating the full stack
app.post(
'/api/v1/accounts',
schemaEnforcer({
email: [{ type: 'email', required: true, maxLength: 255 }],
tier: [{ enum: ['free', 'pro', 'enterprise'], required: true }],
}),
routeGuard(async (req, res) => {
const { email, tier } = req.body;
// Simulated service layer
const exists = await checkAccountExists(email);
if (exists) {
throw new ValidationFailure([{ field: 'email', reason: 'Account already registered' }]);
}
const account = await createAccount({ email, tier });
res.status(201).json({ success: true, data: account });
})
);
// Fallback: must be registered last
app.use(errorDispatcher);
export { app };
Quick Start Guide
- Initialize the error taxonomy: Create
BaseApiError with serialization and operational flagging. Add 3-5 domain subclasses covering your most common failure modes.
- Wire the async guard: Replace all
async (req, res) => {} route handlers with routeGuard(async (req, res) => {}). Remove inline try/catch blocks.
- Deploy the dispatcher: Register
errorDispatcher as the final middleware. Verify that operational errors return structured JSON while programming errors trigger sanitized 500 responses.
- Attach correlation IDs: Add middleware to generate or forward
x-correlation-id headers. Ensure every log entry and error response includes this identifier.
- Validate in staging: Run integration tests against known failure paths. Confirm that clients receive deterministic error codes, retry headers, and zero stack traces in production mode.