Express API Foundation — Routing, Zod Validation, Middleware Stack, and Error Handling

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
Note: Use Zod for request validation rather than express-validator or Joi. Zod provides TypeScript-first schema definition that produces the inferred TypeScript type automatically: 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).
Tip: Structure routes by feature module, not by HTTP method. A 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).
Warning: Never trust 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)))

🧠 Test Yourself

A POST request to /api/v1/tasks has body { "title": "", "workspaceId": "invalid-id" }. With the Zod validation middleware in place, what does the API return?