of duplicating validation logic, create a factory function that returns middleware. This enforces a consistent validation strategy across all routes and allows configuration like abortEarly to collect all errors at once.
File: src/middleware/validate.js
import Joi from 'joi';
export const createValidator = (schema) => {
const options = {
abortEarly: false,
allowUnknown: false,
stripUnknown: true
};
return (req, res, next) => {
const { error, value } = schema.validate(req.body, options);
if (error) {
const messages = error.details.map((detail) => detail.message);
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: messages
});
}
req.validatedBody = value;
next();
};
};
Rationale: abortEarly: false ensures clients receive all validation errors in a single response, reducing round-trips. stripUnknown removes extraneous fields, preventing mass-assignment vulnerabilities. The validated data is attached to req.validatedBody, ensuring controllers never touch raw, untrusted input.
3. Modular Controller Architecture
Separate routing from business logic. Controllers should handle the request/response cycle and delegate data operations. For this example, we use an in-memory store, but the controller interface remains identical when swapping to a database.
File: src/modules/products/product.controller.js
import crypto from 'crypto';
// In-memory store for demonstration
const products = new Map();
export const createProduct = (req, res) => {
const { name, price, category } = req.validatedBody;
const product = {
id: crypto.randomUUID(),
name,
price: Number(price),
category,
createdAt: new Date().toISOString()
};
products.set(product.id, product);
return res.status(201).json({
status: 'success',
data: product
});
};
export const getProduct = (req, res) => {
const { id } = req.params;
const product = products.get(id);
if (!product) {
return res.status(404).json({
status: 'error',
message: 'Product not found'
});
}
return res.status(200).json({
status: 'success',
data: product
});
};
export const listProducts = (req, res) => {
const allProducts = Array.from(products.values());
return res.status(200).json({
status: 'success',
count: allProducts.length,
data: allProducts
});
};
Rationale: Using crypto.randomUUID() provides collision-resistant IDs superior to timestamp-based approaches. The controller returns a consistent envelope (status, data, message), which simplifies client-side parsing. Business logic is isolated, making unit testing straightforward without mocking the HTTP layer.
4. Router Assembly and Validation Binding
The router defines the API surface and binds validation middleware to specific endpoints.
File: src/modules/products/product.router.js
import { Router } from 'express';
import Joi from 'joi';
import { createValidator } from '../../middleware/validate.js';
import * as productController from './product.controller.js';
const router = Router();
const productSchema = Joi.object({
name: Joi.string().min(3).max(100).required(),
price: Joi.number().positive().precision(2).required(),
category: Joi.string().valid('electronics', 'clothing', 'food').required()
});
router.get('/', productController.listProducts);
router.get('/:id', productController.getProduct);
router.post('/', createValidator(productSchema), productController.createProduct);
export default router;
Rationale: Validation is applied only where input is expected (POST). GET requests do not require body validation. The schema enforces type, range, and allowed values, rejecting invalid categories before they reach the controller.
5. Centralized Error Handling
A global error handler catches synchronous errors and rejections from async routes. Define a custom error class to distinguish expected errors from unexpected crashes.
File: src/utils/errors.js
export class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
File: src/middleware/errorHandler.js
import { AppError } from '../utils/errors.js';
export const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
const status = err.status || 'error';
const message = err.isOperational ? err.message : 'Internal Server Error';
// Log full error details in development, sanitize in production
if (process.env.NODE_ENV === 'development') {
console.error(err);
}
res.status(statusCode).json({
status,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
Rationale: Operational errors (4xx) are safe to expose to clients. System errors (5xx) are masked to prevent information leakage. The AppError class allows controllers to throw errors that the handler recognizes, maintaining the JSON contract even during failures.
6. Application Bootstrap
Wire everything together in the entry point. Apply security middleware globally and attach the error handler last.
File: src/index.js
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import productRouter from './modules/products/product.router.js';
import { errorHandler } from './middleware/errorHandler.js';
const app = express();
// Global middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/v1/products', productRouter);
// 404 Handler
app.use((req, res, next) => {
next(new Error(`Route ${req.originalUrl} not found`));
});
// Error Handler (Must be last)
app.use(errorHandler);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Inventory API running on port ${PORT}`);
});
7. Containerization Strategy
Optimize the Docker build to minimize image size and improve security. Use node:alpine and a .dockerignore file.
File: .dockerignore
node_modules
.env
.git
*.md
Dockerfile
docker-compose.yml
File: Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
Rationale: npm ci --omit=dev ensures only production dependencies are installed, reducing size and attack surface. Running as the node user prevents container escape vulnerabilities. The .dockerignore prevents local node_modules from overwriting the clean installation inside the container.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Route/Controller Coupling | Embedding database calls or complex logic inside app.get() makes code untestable and hard to maintain. | Extract logic into controller functions. Routes should only handle HTTP concerns and delegate to controllers. |
| Silent Validation Failures | Accepting malformed data leads to data corruption and security risks. | Implement schema validation middleware on all mutating endpoints. Use abortEarly: false to report all errors. |
| Inconsistent Error Shapes | Returning HTML errors or mixed JSON formats breaks client error handling. | Use a global error handler that always returns a standardized JSON envelope. Create an AppError class for operational errors. |
| Docker Image Bloat | Using full OS images or copying node_modules results in images >500MB, slowing deployments. | Use node:alpine, run npm ci --omit=dev, and use .dockerignore to exclude unnecessary files. |
| Environment Variable Leakage | Hardcoding secrets or committing .env files exposes credentials. | Use dotenv and ensure .env is in .gitignore. Inject variables via CI/CD pipelines or orchestration tools. |
| Missing Security Headers | Default Express configurations lack headers that protect against XSS, clickjacking, and MIME sniffing. | Apply helmet middleware globally to set secure HTTP headers automatically. |
| Blocking the Event Loop | Synchronous operations or heavy CPU tasks freeze the server, rejecting all concurrent requests. | Use async/await for I/O. Offload CPU-intensive tasks to worker threads or external queues. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Data Persistence | PostgreSQL via Prisma/Drizzle | Relational integrity, ACID compliance, mature ecosystem. | Moderate (Managed DB costs). |
| Validation Library | Joi vs. Zod | Joi is mature and feature-rich; Zod offers TypeScript inference. Choose based on TS usage. | Neutral. |
| Deployment Target | Render/Railway vs. AWS ECS | Render/Railway for speed and simplicity; AWS for scale and control. | Low vs. High. |
| Container Base | node:alpine vs. node:slim | Alpine is smallest; Slim offers better glibc compatibility. Prefer Alpine unless native modules fail. | Low (Bandwidth/Storage). |
| Error Handling | Custom AppError vs. Generic | Custom class allows distinguishing operational vs. system errors for masking. | Neutral. |
Configuration Template
Docker Compose for Local Development:
version: '3.8'
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- PORT=3000
volumes:
- ./src:/app/src
command: npm run dev
Environment Template (.env.example):
PORT=3000
NODE_ENV=production
# DATABASE_URL=postgresql://user:pass@host:5432/db
Quick Start Guide
- Clone and Install:
git clone <repo-url>
cd inventory-api
npm install
- Configure:
Copy
.env.example to .env and set your port.
- Run Development Server:
npm run dev
- Verify Endpoint:
curl -X POST http://localhost:3000/api/v1/products \
-H "Content-Type: application/json" \
-d '{"name":"Widget","price":9.99,"category":"electronics"}'
- Build and Run Docker:
docker build -t inventory-api .
docker run -p 3000:3000 inventory-api