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') and process.on('uncaughtException') are handled with graceful shutdown
  • Dependency injection is used — not module-level singletons — for testability
  • No console.log in 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.

Arivanandhan Chitheshwaran