Express.js remains the most popular micro-framework for Node.js backend development. However, because Express is unopinionated, developers often dump business logic, database queries, route handlers, and validation into gigantic single-file routes.
As an application scales, unorganized Express code becomes brittle, prone to memory leaks, and hard to test.
In this deep dive, we will design a modular Controller-Service-Repository architecture, eliminate async try/catch boilerplate, implement security middleware, and handle graceful process shutdowns.
1. Enterprise Layered Architecture: Separation of Concerns
A maintainable Express API separates concerns into distinct architectural layers:
┌──────────────────────────────┐
│ HTTP Client Request │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Routes & Router Layer │
│ (Path matching & Middleware)│
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Controller Layer │
│ (HTTP status, Req/Res mapping)│
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Service Layer │
│ (Core Business & Domain Rules)│
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Repository Layer │
│ (ORM / Database SQL Queries)│
└──────────────────────────────┘
- Routes Layer: Defines endpoints and attaches validation/authentication middleware.
- Controller Layer: Parses HTTP inputs (
req.params,req.body), calls services, and sends HTTP status codes. Zero business logic live here. - Service Layer: Houses domain rules, calculations, payment integrations, and emails. Agnostic of Express
reqandresobjects. - Repository Layer: Encapsulates database queries (Prisma, TypeORM, Kysely, or raw SQL).
2. Eliminating try/catch Boilerplate in Async Handlers
Wrapping every controller function in a redundant try/catch block leads to repetitive clutter:
// ❌ REPETITIVE ANTI-PATTERN: Bloated with duplicate try/catch blocks
export const getUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await userService.findById(req.params.id);
res.json(user);
} catch (error) {
next(error); // Mandatory manual forwarding to Express error handler
}
};
The asyncHandler Higher-Order Wrapper Solution
Create a lightweight utility function that intercepts rejected promises and forwards errors to the global error middleware automatically:
// utils/asyncHandler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
export const asyncHandler = (fn: RequestHandler): RequestHandler => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// ✅ CLEAN CONTROLLER IMPLEMENTATION:
export const getUser = asyncHandler(async (req: Request, res: Response) => {
const user = await userService.findById(req.params.id);
res.json(user); // Errors are automatically caught and passed to global error pipeline!
});
3. Request Validation with Zod Middleware
Never trust client input. Implement a generic Zod schema validation middleware that validates req.body, req.query, and req.params:
// middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
export const validateRequest = (schema: AnyZodObject) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
status: 'fail',
message: 'Validation failed',
errors: error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
});
}
next(error);
}
};
};
// Usage Example with User Creation Schema
import { z } from 'zod';
export const CreateUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'Must be at least 18 years old'),
}),
});
// Attach to Route
router.post('/users', validateRequest(CreateUserSchema), createUserController);
4. Production Security Middleware Stack
Configure security defaults at the top of your Express app initialization chain:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
const app = express();
// 1. Security Headers with Helmet
app.use(helmet());
// 2. Controlled CORS configuration
app.use(
cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['https://yourdomain.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
credentials: true,
})
);
// 3. Global Rate Limiter to prevent DoS attacks
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: { status: 429, message: 'Too many requests, please try again later.' },
});
app.use('/api/', limiter);
// 4. Body Parsers with payload size limits
app.use(express.json({ limit: '10kb' })); // Prevents huge payload memory exhaustion
5. Graceful Shutdown Signal Handling
When deploying Express apps on Kubernetes or platforms like Render/AWS ECS, container restarts send a SIGTERM signal.
If you kill the Node.js process instantly, active HTTP connections drop mid-transaction, causing corrupted database writes and API errors for connected clients.
Implement Graceful Shutdown with connection draining:
import http from 'http';
import app from './app';
import { db } from './db';
const PORT = process.env.PORT || 4000;
const server = http.createServer(app);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
async function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// 1. Stop accepting new incoming HTTP connections
server.close(async () => {
console.log('HTTP server closed.');
try {
// 2. Disconnect database connections cleanly
await db.disconnect();
console.log('Database connections closed.');
// 3. Exit process cleanly
process.exit(0);
} catch (err) {
console.error('Error during graceful shutdown:', err);
process.exit(1);
}
});
// Force exit after 10 seconds if connections refuse to close
setTimeout(() => {
console.error('Forced shutdown after timeout.');
process.exit(1);
}, 10000);
}
// Intercept OS Signals
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Production Checklist Summary
- Separate code into Routes, Controllers, Services, and Repositories.
- Use
asyncHandlerwrappers to centralize error handling without redundanttry/catch. - Enforce Zod input validation on request parameters, queries, and bodies.
- Hardened security using
helmet, strictcors, andexpress-rate-limit. - Listen for
SIGTERM/SIGINTsignals to perform graceful database and connection draining shutdown.