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
| Command | What it does |
|---|---|
pm2 start server.js | Start the app as a background daemon |
pm2 start server.js --name blog-api | Start with a custom process name |
pm2 list | Show all running processes |
pm2 status | Same as pm2 list (alias) |
pm2 stop blog-api | Stop a process by name |
pm2 restart blog-api | Restart a process |
pm2 reload blog-api | Zero-downtime restart (graceful reload) |
pm2 delete blog-api | Remove a process from PM2's list |
pm2 logs | Stream all process logs |
pm2 logs blog-api | Stream logs for a specific process |
pm2 monit | Terminal-based monitoring dashboard |
pm2 save | Save current process list for auto-start on reboot |
pm2 startup | Generate 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 type | isOperational | Example | What to tell the client |
|---|---|---|---|
| Operational | true | User not found, invalid input, duplicate email | The actual error message |
| Programming | false | TypeError, ReferenceError, unhandled rejection | Generic "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
asyncHandlertakes your async function as an argument- Returns a new function that Express calls with
(req, res, next) - Wraps the execution in
Promise.resolve().catch() - If the async function throws or rejects, the error is passed to
next() - 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 consistency | With ApiResponse / ApiError |
|---|---|
Frontend checks res.data, res.user, res.result depending on endpoint | Always res.data.data |
Error format varies: { error: "..." }, { message: "..." }, { msg: "..." } | Always { success: false, message: "..." } |
| Status codes are inconsistent | Controller 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
| Option | Purpose | Production value |
|---|---|---|
origin | Which domains can make requests | Whitelist of your frontend URLs |
credentials | Allow cookies/auth headers | true if using JWT in cookies |
methods | Allowed HTTP methods | Only the methods your API uses |
allowedHeaders | Headers the client can send | Content-Type, Authorization |
exposedHeaders | Custom response headers the client can read | Pagination headers like X-Total-Count |
maxAge | How long browsers cache the preflight result | 86400 (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
- PM2 is the standard Node.js process manager --- use cluster mode, ecosystem config, and
pm2 startupfor auto-restart on reboot. ApiErrorextendsErrorwithstatusCodeandisOperational--- operational errors get real messages, programming errors get generic ones.asyncHandlereliminates repetitivetry/catchblocks in every route handler.- Global error handler catches Mongoose errors, JWT errors, and custom errors in one place --- it must be the last middleware.
ApiResponsegives every successful response the same shape:{ statusCode, data, message, success }.- CORS in production means whitelisting specific origins, not using
cors()with defaults.
Explain-It Challenge
Explain without notes:
- What happens if your PM2 cluster has 4 workers and one crashes? How does PM2 handle it?
- Why does the global error handler middleware need exactly four parameters (
err, req, res, next)? - 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 ->