Episode 3 — NodeJS MongoDB Backend Architecture / 3.13 — Production Project Structure

3.13.d --- Production Environment

In one sentence: A production Node.js application needs a process manager to stay alive, standardised error and response handling to behave predictably, and careful CORS configuration to serve real clients --- this lesson covers PM2, custom error classes, async wrappers, and response formatting.

Navigation: <- 3.13.c Configuration Management | 3.13.e --- ESLint & Prettier ->


1. PM2: Process Manager for Node.js

In development you run node server.js and keep the terminal open. In production, you need:

  • Auto-restart if the process crashes
  • Cluster mode to use all CPU cores
  • Log management with rotation
  • Zero-downtime reloads during deployments
  • Monitoring dashboards

PM2 handles all of this.

Installation

# Install globally (available as a CLI command)
npm install -g pm2

Basic commands

CommandWhat it does
pm2 start server.jsStart the app as a background daemon
pm2 start server.js --name blog-apiStart with a custom process name
pm2 listShow all running processes
pm2 statusSame as pm2 list (alias)
pm2 stop blog-apiStop a process by name
pm2 restart blog-apiRestart a process
pm2 reload blog-apiZero-downtime restart (graceful reload)
pm2 delete blog-apiRemove a process from PM2's list
pm2 logsStream all process logs
pm2 logs blog-apiStream logs for a specific process
pm2 monitTerminal-based monitoring dashboard
pm2 saveSave current process list for auto-start on reboot
pm2 startupGenerate OS startup script (systemd/upstart)

Cluster mode

Node.js is single-threaded. PM2's cluster mode spawns multiple instances to use all CPU cores:

# Start with max CPU cores
pm2 start server.js -i max

# Start with specific number of instances
pm2 start server.js -i 4

# Start with max minus 1 (leave one core for the OS)
pm2 start server.js -i -1

How it works:

                    PM2 Master Process
                    /    |    |    \
               Worker  Worker  Worker  Worker
               (CPU 0) (CPU 1) (CPU 2) (CPU 3)
                    \    |    |    /
                  All share the same port

PM2 uses Node.js cluster module internally. Each worker is a separate process with its own memory. PM2 load-balances incoming requests across workers using round-robin.

ecosystem.config.js

For production, define your PM2 configuration in a file instead of passing CLI flags:

// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'blog-api',
      script: './server.js',
      instances: 'max',           // Cluster mode with all CPUs
      exec_mode: 'cluster',       // Required for cluster mode
      autorestart: true,          // Restart on crash
      watch: false,               // Don't watch files in production
      max_memory_restart: '1G',   // Restart if memory exceeds 1GB
      env: {
        NODE_ENV: 'development',
        PORT: 3000,
      },
      env_production: {
        NODE_ENV: 'production',
        PORT: 8080,
      },
      // Logging
      error_file: './logs/pm2-error.log',
      out_file: './logs/pm2-out.log',
      log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
      merge_logs: true,           // Merge cluster worker logs
      // Graceful shutdown
      kill_timeout: 5000,         // Wait 5s for connections to close
      listen_timeout: 10000,      // Wait 10s for app to signal ready
    },
  ],
};

Starting with ecosystem config

# Development environment
pm2 start ecosystem.config.js

# Production environment
pm2 start ecosystem.config.js --env production

# Restart all
pm2 restart ecosystem.config.js --env production

Auto-start on server reboot

# Generate startup script (run once)
pm2 startup
# PM2 prints a command --- copy and run it with sudo

# Save current process list
pm2 save

# Now PM2 and your apps auto-start after reboot

2. Error handling configuration

Production apps need a structured, consistent way to handle errors.

Custom ApiError class

// src/utils/ApiError.js
class ApiError extends Error {
  constructor(statusCode, message = 'Something went wrong', errors = [], stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.message = message;
    this.errors = errors;           // Array of specific field errors
    this.data = null;
    this.success = false;
    this.isOperational = true;      // Distinguishes from programming errors

    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

module.exports = ApiError;

Using ApiError in services

// src/services/user.service.js
const ApiError = require('../utils/ApiError');
const User = require('../models/user.model');

const getUserById = async (id) => {
  const user = await User.findById(id);
  if (!user) {
    throw new ApiError(404, 'User not found');
  }
  return user;
};

const createUser = async (data) => {
  const existingUser = await User.findOne({ email: data.email });
  if (existingUser) {
    throw new ApiError(409, 'A user with this email already exists');
  }
  return User.create(data);
};

isOperational explained

Error typeisOperationalExampleWhat to tell the client
OperationaltrueUser not found, invalid input, duplicate emailThe actual error message
ProgrammingfalseTypeError, ReferenceError, unhandled rejectionGeneric "Something went wrong"

Operational errors are expected and handled. Programming errors are bugs --- you log them, alert the team, and give the client a safe generic message.


3. asyncHandler / catchAsync wrapper

Every async route handler needs a try/catch block. Without a wrapper, you write this repeatedly:

// WITHOUT asyncHandler --- repetitive try/catch
const getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    res.json(user);
  } catch (error) {
    next(error);
  }
};

The asyncHandler utility

// src/utils/asyncHandler.js
const asyncHandler = (requestHandler) => {
  return (req, res, next) => {
    Promise.resolve(requestHandler(req, res, next)).catch((err) => next(err));
  };
};

module.exports = asyncHandler;

How it works

  1. asyncHandler takes your async function as an argument
  2. Returns a new function that Express calls with (req, res, next)
  3. Wraps the execution in Promise.resolve().catch()
  4. If the async function throws or rejects, the error is passed to next()
  5. Express routes it to the global error handler

Using asyncHandler

// WITH asyncHandler --- clean, no try/catch
const asyncHandler = require('../utils/asyncHandler');

const getUser = asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new ApiError(404, 'User not found');
  }
  res.json(new ApiResponse(200, user, 'User fetched'));
});

Alternative: express-async-errors package

npm install express-async-errors
// In app.js (import once, patches Express globally)
require('express-async-errors');

// Now all async route handlers automatically forward errors to next()
// No wrapper needed
const getUser = async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);
  // If findById throws, Express catches it automatically
};

4. Global error handler middleware

The global error handler is the last middleware in the Express chain. It catches all errors forwarded by next(err).

// src/middleware/error.middleware.js
const ApiError = require('../utils/ApiError');
const config = require('../config');

const errorHandler = (err, req, res, next) => {
  // Default values
  let statusCode = err.statusCode || 500;
  let message = err.message || 'Internal Server Error';

  // Handle specific Mongoose errors
  if (err.name === 'CastError') {
    statusCode = 400;
    message = `Invalid ${err.path}: ${err.value}`;
  }

  if (err.code === 11000) {
    statusCode = 409;
    const field = Object.keys(err.keyValue)[0];
    message = `Duplicate value for field: ${field}`;
  }

  if (err.name === 'ValidationError') {
    statusCode = 400;
    const messages = Object.values(err.errors).map((e) => e.message);
    message = `Validation failed: ${messages.join(', ')}`;
  }

  if (err.name === 'JsonWebTokenError') {
    statusCode = 401;
    message = 'Invalid token. Please log in again.';
  }

  if (err.name === 'TokenExpiredError') {
    statusCode = 401;
    message = 'Token expired. Please log in again.';
  }

  // Build response
  const response = {
    success: false,
    statusCode,
    message,
    ...(err.errors && { errors: err.errors }),
    ...(config.env === 'development' && { stack: err.stack }),
  };

  // Log error (in production, use a proper logger)
  if (statusCode >= 500) {
    console.error('SERVER ERROR:', err);
  }

  res.status(statusCode).json(response);
};

module.exports = { errorHandler };

Register it last in app.js

// src/app.js
const { errorHandler } = require('./middleware/error.middleware');

// ... all routes ...

// 404 handler (for unmatched routes)
app.use((req, res, next) => {
  next(new ApiError(404, `Route not found: ${req.method} ${req.originalUrl}`));
});

// Global error handler (MUST have 4 parameters)
app.use(errorHandler);

5. Response handling: ApiResponse class

Consistent response formatting makes your API predictable for frontend developers.

// src/utils/ApiResponse.js
class ApiResponse {
  constructor(statusCode, data, message = 'Success') {
    this.statusCode = statusCode;
    this.data = data;
    this.message = message;
    this.success = statusCode < 400;
  }
}

module.exports = ApiResponse;

Using ApiResponse in controllers

const ApiResponse = require('../utils/ApiResponse');

// Single resource
res.status(200).json(new ApiResponse(200, user, 'User fetched successfully'));

// Collection
res.status(200).json(new ApiResponse(200, { users, total, page, limit }, 'Users fetched'));

// Created
res.status(201).json(new ApiResponse(201, newPost, 'Post created successfully'));

// Deleted
res.status(200).json(new ApiResponse(200, null, 'Post deleted successfully'));

What the client receives

Success response:

{
  "statusCode": 200,
  "data": {
    "id": "65a1b2c3d4e5f6a7b8c9d0e1",
    "name": "John Doe",
    "email": "john@example.com"
  },
  "message": "User fetched successfully",
  "success": true
}

Error response:

{
  "success": false,
  "statusCode": 404,
  "message": "User not found"
}

Why consistent responses matter

Without consistencyWith ApiResponse / ApiError
Frontend checks res.data, res.user, res.result depending on endpointAlways res.data.data
Error format varies: { error: "..." }, { message: "..." }, { msg: "..." }Always { success: false, message: "..." }
Status codes are inconsistentController always sets correct HTTP status

6. CORS configuration for production

Cross-Origin Resource Sharing (CORS) controls which domains can call your API from a browser.

Development (wide open)

const cors = require('cors');
app.use(cors()); // Allows ALL origins --- fine for development

Production (whitelist specific origins)

// src/config/cors.config.js
const config = require('./index');
const ApiError = require('../utils/ApiError');

const allowedOrigins = config.cors.origin.split(',').map((o) => o.trim());
// .env: CORS_ORIGIN=https://myapp.com,https://admin.myapp.com

const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (mobile apps, Postman, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new ApiError(403, `Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,              // Allow cookies and Authorization headers
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Requested-With',
    'Accept',
  ],
  exposedHeaders: ['X-Total-Count'],  // Custom headers the client can read
  maxAge: 86400,                      // Cache preflight for 24 hours
};

module.exports = corsOptions;

Using in app.js

// src/app.js
const cors = require('cors');
const corsOptions = require('./config/cors.config');

app.use(cors(corsOptions));

CORS options explained

OptionPurposeProduction value
originWhich domains can make requestsWhitelist of your frontend URLs
credentialsAllow cookies/auth headerstrue if using JWT in cookies
methodsAllowed HTTP methodsOnly the methods your API uses
allowedHeadersHeaders the client can sendContent-Type, Authorization
exposedHeadersCustom response headers the client can readPagination headers like X-Total-Count
maxAgeHow long browsers cache the preflight result86400 (24 hours) reduces OPTIONS requests

7. Putting it all together: production app.js

// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
const rateLimit = require('express-rate-limit');

const config = require('./config');
const corsOptions = require('./config/cors.config');
const routes = require('./routes');
const ApiError = require('./utils/ApiError');
const { errorHandler } = require('./middleware/error.middleware');

const app = express();

// --- Security ---
app.use(helmet());
app.use(cors(corsOptions));

// --- Rate limiting ---
const limiter = rateLimit({
  windowMs: config.rateLimit.windowMs,
  max: config.rateLimit.max,
  message: { success: false, message: 'Too many requests, please try again later' },
});
app.use('/api', limiter);

// --- Body parsing ---
app.use(express.json({ limit: '16kb' }));
app.use(express.urlencoded({ extended: true, limit: '16kb' }));

// --- Compression ---
app.use(compression());

// --- Logging ---
if (config.env === 'development') {
  app.use(morgan('dev'));
} else {
  app.use(morgan('combined'));
}

// --- Static files ---
app.use('/public', express.static('public'));

// --- Health check ---
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'ok',
    environment: config.env,
    timestamp: new Date().toISOString(),
  });
});

// --- API routes ---
app.use('/api/v1', routes);

// --- 404 handler ---
app.use((req, res, next) => {
  next(new ApiError(404, `Route not found: ${req.method} ${req.originalUrl}`));
});

// --- Global error handler ---
app.use(errorHandler);

module.exports = app;

8. Key takeaways

  1. PM2 is the standard Node.js process manager --- use cluster mode, ecosystem config, and pm2 startup for auto-restart on reboot.
  2. ApiError extends Error with statusCode and isOperational --- operational errors get real messages, programming errors get generic ones.
  3. asyncHandler eliminates repetitive try/catch blocks in every route handler.
  4. Global error handler catches Mongoose errors, JWT errors, and custom errors in one place --- it must be the last middleware.
  5. ApiResponse gives every successful response the same shape: { statusCode, data, message, success }.
  6. CORS in production means whitelisting specific origins, not using cors() with defaults.

Explain-It Challenge

Explain without notes:

  1. What happens if your PM2 cluster has 4 workers and one crashes? How does PM2 handle it?
  2. Why does the global error handler middleware need exactly four parameters (err, req, res, next)?
  3. Trace what happens when an async controller throws an ApiError(404, 'Not found') --- from the throw to the JSON response reaching the client.

Navigation: <- 3.13.c Configuration Management | 3.13.e --- ESLint & Prettier ->