The Express API backbone is the scaffolding of routes, middleware, validation, and error handling that every request passes through before reaching business logic. A well-structured Express application separates concerns cleanly: validation middleware catches bad input before it reaches controllers, authentication middleware verifies identity, authorisation middleware checks permissions, and the global error handler turns every error type — Mongoose, custom AppError, or unexpected — into the correct HTTP response. This lesson assembles the complete Express application foundation for the Task Manager capstone.
Request Lifecycle
| Layer | Middleware | Responsibility |
|---|---|---|
| 1. Security | Helmet, CORS | Set security headers, allow cross-origin requests |
| 2. Rate limiting | globalRateLimiter | Block abuse before any processing |
| 3. Parsing | express.json(), express.urlencoded() | Parse request body |
| 4. Logging | Morgan → Winston | Log every request |
| 5. Correlation | correlationMiddleware | Assign request ID for log tracing |
| 6. Authentication | verifyAccessToken | Verify JWT, attach req.user |
| 7. Authorisation | requireWorkspaceMember | Check workspace membership and role |
| 8. Validation | validateBody(schema) | Validate and sanitise request input |
| 9. Controller | Route handler | Business logic, call service |
| 10. Error handler | errorMiddleware | Convert all errors to JSON responses |
const schema = z.object({ title: z.string().min(1).max(500) }); type CreateTaskDto = z.infer<typeof schema>. The same schema validates the request body AND types the validated output — no need to separately define a TypeScript interface. The shared package can export Zod schemas for use in both the API (validation) and the Angular client (client-side validation).tasks/ module contains task.routes.ts, task.controller.ts, task.service.ts, task.model.ts, and task.validation.ts — everything related to tasks is co-located. This is the vertical slice architecture: adding a new feature means adding a new folder, not modifying five existing files across different directories. It scales much better than horizontal layers (all controllers in one folder, all services in another).req.params.id directly in Mongoose queries without validating it is a valid MongoDB ObjectId first. mongoose.isValidObjectId(req.params.id) returns false for strings like 'undefined', 'null', or arbitrary strings — passing these to Task.findById() throws a CastError. Add an ObjectId validation step in the validation middleware or a shared validator function, and return a 400 Bad Request before attempting any database query with an invalid ID.Complete Express App Configuration
// ── apps/api/src/app.js ───────────────────────────────────────────────────
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const mongoose = require('mongoose');
const { morganMiddleware } = require('./config/morgan');
const { correlationMiddleware } = require('./config/logger');
const { globalRateLimiter } = require('./middleware/rate-limiter');
const errorMiddleware = require('./middleware/error.middleware');
const { NotFoundError } = require('./errors/app-errors');
// Feature routers
const authRoutes = require('./modules/auth/auth.routes');
const userRoutes = require('./modules/users/user.routes');
const workspaceRoutes = require('./modules/workspaces/workspace.routes');
const taskRoutes = require('./modules/tasks/task.routes');
const uploadRoutes = require('./modules/uploads/upload.routes');
const searchRoutes = require('./modules/search/search.routes');
const app = express();
// ── Security ─────────────────────────────────────────────────────────────
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://res.cloudinary.com'],
},
},
}));
app.use(cors({
origin: (process.env.CORS_ORIGINS || '').split(',').map(o => o.trim()),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
}));
// ── Request infrastructure ────────────────────────────────────────────────
app.use(globalRateLimiter); // rate limit before parsing — save CPU on abusive IPs
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(morganMiddleware);
app.use(correlationMiddleware);
// ── Trust proxy — for correct IP behind nginx/load balancer ───────────────
app.set('trust proxy', 1);
// ── Health routes (no auth required) ─────────────────────────────────────
app.get('/api/v1/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// ── Feature routes ────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', userRoutes);
app.use('/api/v1/workspaces', workspaceRoutes);
app.use('/api/v1/tasks', taskRoutes);
app.use('/api/v1/uploads', uploadRoutes);
app.use('/api/v1/search', searchRoutes);
// ── 404 handler ───────────────────────────────────────────────────────────
app.use((req, res, next) => {
next(new NotFoundError('Route', req.path));
});
// ── Global error handler ──────────────────────────────────────────────────
app.use(errorMiddleware);
module.exports = app;
// ── Task routes with validation middleware ────────────────────────────────
// apps/api/src/modules/tasks/task.routes.js
const { Router } = require('express');
const { verifyAccessToken } = require('../../middleware/auth.middleware');
const { requireWorkspaceMember } = require('../../middleware/workspace.middleware');
const { validateBody, validateParams } = require('../../middleware/validate.middleware');
const { authLimiter } = require('../../middleware/rate-limiter');
const taskController = require('./task.controller');
const { createTaskSchema, updateTaskSchema, taskParamsSchema } = require('./task.validation');
const router = Router();
// All task routes require authentication
router.use(verifyAccessToken);
router.get('/',
// Query params validation handled in controller/service
taskController.getAll
);
router.post('/',
validateBody(createTaskSchema),
requireWorkspaceMember('member'), // must be at least a member to create
taskController.create
);
router.get('/:id',
validateParams(taskParamsSchema),
taskController.getById
);
router.patch('/:id',
validateParams(taskParamsSchema),
validateBody(updateTaskSchema),
taskController.update
);
router.delete('/:id',
validateParams(taskParamsSchema),
requireWorkspaceMember('member'),
taskController.softDelete
);
module.exports = router;
// ── Validation middleware with Zod ────────────────────────────────────────
// apps/api/src/middleware/validate.middleware.js
const { z } = require('zod');
const { ValidationError }= require('../errors/app-errors');
const mongoose = require('mongoose');
// ObjectId validator
const objectIdSchema = z.string().refine(
id => mongoose.isValidObjectId(id),
{ message: 'Invalid ID format' }
);
function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const fields = Object.fromEntries(
result.error.errors.map(e => [e.path.join('.'), e.message])
);
return next(new ValidationError('Validation failed', fields));
}
req.body = result.data; // replace with parsed/coerced data
next();
};
}
function validateParams(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.params);
if (!result.success) {
return next(new ValidationError('Invalid request parameters',
Object.fromEntries(result.error.errors.map(e => [e.path.join('.'), e.message]))
));
}
req.params = result.data;
next();
};
}
module.exports = { validateBody, validateParams, objectIdSchema };
// ── Task validation schemas ───────────────────────────────────────────────
// apps/api/src/modules/tasks/task.validation.js
const { z } = require('zod');
const { objectIdSchema }= require('../../middleware/validate.middleware');
const createTaskSchema = z.object({
title: z.string().trim().min(1, 'Title is required').max(500),
description: z.string().trim().max(10000).optional(),
status: z.enum(['todo','in-progress','in-review','done','cancelled']).optional(),
priority: z.enum(['none','low','medium','high','urgent']).optional(),
dueDate: z.string().datetime({ offset: true }).optional(),
startDate: z.string().datetime({ offset: true }).optional(),
tags: z.array(z.string().trim().max(50)).max(10).optional(),
assignees: z.array(objectIdSchema).max(20).optional(),
workspaceId: objectIdSchema,
});
const updateTaskSchema = createTaskSchema.omit({ workspaceId: true }).partial();
const taskParamsSchema = z.object({ id: objectIdSchema });
module.exports = { createTaskSchema, updateTaskSchema, taskParamsSchema };
How It Works
Step 1 — Rate Limiting Before Body Parsing Saves CPU
Placing globalRateLimiter before express.json() means requests from rate-limited IPs are rejected before their bodies are parsed. A 1MB JSON body requires CPU and memory to parse even if it is going to be rejected. For abusive clients sending many large requests, rate limiting before parsing reduces the CPU cost of handling them from milliseconds (parse + reject) to microseconds (count check + reject).
Step 2 — trust proxy Enables Correct IP Extraction
Behind a reverse proxy (nginx, load balancer), req.ip returns the proxy’s internal IP unless Express is told to trust the X-Forwarded-For header. app.set('trust proxy', 1) trusts one hop of proxies — the first value in X-Forwarded-For is used as req.ip. Without this, all rate limiting keys are the same proxy IP — every user shares one rate limit bucket.
Step 3 — Zod’s safeParse Produces Structured Error Details
schema.safeParse(data) returns { success: true, data: parsed } or { success: false, error: ZodError } without throwing. The ZodError.errors array contains structured error objects with the field path and message. Converting this to a field-keyed object ({ title: 'Title is required', workspaceId: 'Invalid ID format' }) and wrapping it in a ValidationError produces a user-friendly validation response with per-field messages.
Step 4 — req.body Replacement with Parsed Data
After Zod parses and coerces req.body, replacing req.body with result.data ensures downstream middleware and controllers receive clean, type-correct data. Zod applies transforms (trim, lowercase, coerce string to number) during parsing. The controller never receives raw string dates — they are already validated ISO strings. It also strips unknown fields by default, preventing prototype pollution or unexpected fields from reaching the database.
Step 5 — Vertical Slice Architecture Scales the Codebase
Each feature module (tasks, auth, workspaces) contains all its own files — router, controller, service, model, validation, and tests. Adding the “Comments” feature means creating a new comments/ folder with its own slice, touching no existing files. This contrasts with horizontal architecture (all controllers in one folder, all services in another) where adding a feature requires modifying multiple folders and risks introducing regressions in unrelated features.
Quick Reference
| Task | Code |
|---|---|
| Validate body | router.post('/', validateBody(schema), controller.create) |
| Validate params | router.get('/:id', validateParams(paramSchema), controller.get) |
| Zod schema | z.object({ title: z.string().min(1).max(500), workspaceId: objectIdSchema }) |
| Partial update schema | createSchema.omit({ workspaceId: true }).partial() |
| Validate ObjectId | mongoose.isValidObjectId(id) |
| Trust proxy | app.set('trust proxy', 1) |
| CORS with credentials | cors({ origin: [...], credentials: true }) |
| 404 handler | app.use((req, res, next) => next(new NotFoundError('Route', req.path))) |