Express.js is the most popular Node.js web framework. Its flexibility means you must make deliberate decisions about structure and middleware to build something production-ready.
1. Project Structure
src/
├── config/ # Environment configuration
├── middleware/ # Express middleware
├── routes/ # Route definitions (thin)
├── controllers/ # Request handling
├── services/ # Business logic
├── models/ # Data access
├── validators/ # Request schemas
├── errors/ # Custom error classes
└── app.js # Express setup
Key principle: Routes don't contain logic, controllers don't access the database, services don't format HTTP responses.
// routes/users.js — thin
router.get('/:id', authenticate, usersController.getById);
// controllers/users.js — request handling
exports.getById = async (req, res, next) => {
try {
const user = await usersService.getById(req.params.id);
res.json({ data: user });
} catch (err) {
next(err);
}
};
// services/users.js — business logic
exports.getById = async (id) => {
const user = await User.findByPk(id);
if (!user) throw new NotFoundError('User not found');
return user;
};
2. Centralized Error Handling
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
if (!err.isOperational) {
logger.error('Unexpected error:', err);
}
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.isOperational ? err.message : 'An unexpected error occurred',
},
});
}
Mount it last: app.use(errorHandler);
3. Input Validation with Joi
// validators/user.js
const Joi = require('joi');
exports.createUser = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
name: Joi.string().min(1).max(100).required(),
});
// middleware/validate.js
const validate = (schemaName) => (req, res, next) => {
const schema = require('../validators')[schemaName];
const { error, value } = schema.validate(req.body, {
abortEarly: false, stripUnknown: true,
});
if (error) {
const messages = error.details.map(d => d.message).join('; ');
return next(new ValidationError(messages));
}
req.body = value;
next();
};
// Usage
router.post('/', validate('createUser'), usersController.create);
4. Rate Limiting
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
message: { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests' } },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 login attempts per 15 min
skipSuccessfulRequests: true,
});
app.use('/api/', apiLimiter);
app.use('/api/users/login', authLimiter);
5. Logging and Request Tracing
const { v4: uuidv4 } = require('uuid');
// Request ID
app.use((req, res, next) => {
req.requestId = req.headers['x-request-id'] || uuidv4();
res.setHeader('x-request-id', req.requestId);
next();
});
// Request logging
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({ requestId: req.requestId, method: req.method,
url: req.originalUrl, status: res.statusCode,
duration: Date.now() - start });
});
next();
});
6. Health Checks and Graceful Shutdown
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
function gracefulShutdown(signal) {
console.log(`Received ${signal}, shutting down...`);
server.close(() => { db.close(); process.exit(0); });
setTimeout(() => { process.exit(1); }, 30000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
7. Security Middleware
const helmet = require('helmet');
const cors = require('cors');
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN }));
app.use(express.json({ limit: '10kb' }));
Final Assembly
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const app = express();
app.use(helmet());
app.use(cors({ origin: config.corsOrigin }));
app.use(express.json({ limit: '10kb' }));
app.use(requestId);
app.use(requestLogger);
app.use('/api', routes);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use(errorHandler);
module.exports = app;
The difference between a hobby Express app and a production Express app isn't the framework — it's the structure: layered architecture, centralized error handling, input validation, rate limiting, and proper configuration management.