th Zod
Input validation must occur before business logic execution. Using a schema-first approach with zod provides runtime validation, TypeScript type inference, and automatic error mapping.
Architecture Decision: Create a generic validation middleware that accepts a schema. This reduces boilerplate and centralizes error transformation.
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
// Validate body, params, and query based on schema structure
const parsed = schema.parse({
body: req.body,
params: req.params,
query: req.query
});
// Merge parsed data back into request for downstream handlers
req.validated = parsed;
next();
} catch (err) {
if (err instanceof ZodError) {
const violations = err.errors.map(e => ({
field: e.path.join('.'),
issue: e.message
}));
res.envelope.failure('VALIDATION_ERROR', 'Input validation failed', violations, 422);
return;
}
next(err);
}
};
}
Usage Example:
// src/routes/accounts.ts
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate';
const router = Router();
const CreateAccountSchema = z.object({
body: z.object({
contact_address: z.string().email(),
display_name: z.string().min(2).max(100),
role: z.enum(['user', 'admin']).default('user')
})
});
router.post(
'/',
validate(CreateAccountSchema),
async (req, res) => {
const { contact_address, display_name, role } = req.validated.body;
// Business logic...
const account = await AccountService.create({ contact_address, display_name, role });
res.envelope.success(account, { created_at: new Date().toISOString() }, 201);
}
);
3. Pagination and Cursor Strategy
List endpoints must return metadata to enable efficient client-side navigation. Support both offset-based pagination for simple use cases and cursor-based pagination for large datasets.
// src/utils/pagination.ts
export interface PaginationMeta {
page?: number;
per_page: number;
total?: number;
total_pages?: number;
cursor?: string;
has_more: boolean;
}
export function parsePagination(req: Request): { limit: number; offset: number; cursor?: string } {
const limit = Math.min(100, Math.max(1, parseInt(req.query.per_page as string) || 20));
const cursor = req.query.cursor as string | undefined;
if (cursor) {
return { limit, cursor };
}
const page = Math.max(1, parseInt(req.query.page as string) || 1);
return { limit, offset: (page - 1) * limit };
}
4. Rate Limiting with Transparency
Rate limiting protects infrastructure and ensures fair usage. Clients must receive explicit headers indicating their quota status and retry timing.
Architecture Decision: Use express-rate-limit with a Redis store for distributed environments. Configure standard headers for interoperability.
// src/middleware/rate-limit.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
export const globalLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:global:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true, // Returns RateLimit-* headers
legacyHeaders: false,
skipFailedRequests: true,
keyGenerator: (req) => req.ip || 'unknown'
});
export const authLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:auth:'
}),
windowMs: 60 * 1000, // 1 minute
max: 5,
message: { fault: { type: 'RATE_LIMITED', description: 'Too many authentication attempts' } },
standardHeaders: true
});
5. Authentication and Role-Based Access
JWT-based authentication should be stateless and verified via middleware. Role checks should be composable to allow flexible authorization policies.
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
interface JwtPayload {
sub: string;
role: string;
exp: number;
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.envelope.failure('UNAUTHORIZED', 'Missing or malformed token', [], 401);
return;
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
res.envelope.failure('TOKEN_EXPIRED', 'Authentication token has expired', [], 401);
} else {
res.envelope.failure('UNAUTHORIZED', 'Invalid token', [], 401);
}
}
}
export function authorize(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
res.envelope.failure('FORBIDDEN', 'Insufficient permissions', [], 403);
return;
}
next();
};
}
6. Versioning and Deprecation
Versioning isolates breaking changes. URL-based versioning is the most explicit method for consumers. Implement deprecation headers to warn clients of upcoming changes.
// src/app.ts
import express from 'express';
import v1Router from './routes/v1';
import v2Router from './routes/v2';
const app = express();
// Mount versions
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Deprecation middleware for v1
app.use('/api/v1', (req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Dec 2026 00:00:00 GMT');
res.set('Link', '<https://docs.example.com/migration/v2>; rel="successor-version"');
next();
});
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| The "Success" Boolean Trap | Returning { success: true } alongside HTTP 200 forces clients to check two signals. This is redundant and error-prone. | Rely exclusively on HTTP status codes for success/failure. Remove boolean flags from response envelopes. |
| Stack Trace Leakage | Exposing internal stack traces or database errors in production responses reveals implementation details and security vulnerabilities. | Implement a global error handler that catches exceptions and returns a generic { fault: { type: 'INTERNAL_ERROR' } } response. Log details server-side only. |
| Pagination Without Bounds | Allowing unlimited per_page values or missing pagination metadata enables denial-of-service attacks and causes client memory exhaustion. | Enforce a maximum per_page limit (e.g., 100). Always return total and has_more metadata. Use cursors for large datasets. |
| Inconsistent Error Keys | Mixing error, errors, message, and detail across endpoints forces clients to write complex parsing logic. | Define a strict error schema (e.g., fault.type, fault.description, fault.violations) and enforce it via the envelope middleware. |
| Missing Idempotency | POST and PUT requests without idempotency keys can cause duplicate resources or inconsistent state if clients retry due to network timeouts. | Support Idempotency-Key headers for write operations. Store keys with a TTL and return the cached response on duplicate requests. |
| Over-Privileged Tokens | Embedding excessive user data or permissions in JWT payloads increases token size and risk if the token is compromised. | Keep JWT payloads minimal (e.g., sub, role, exp). Fetch additional user context from the database or cache using the subject ID. |
| Ignoring Request IDs | Without unique request identifiers, correlating client errors with server logs becomes nearly impossible in distributed systems. | Generate a x-request-id header for every request. Propagate this ID through all downstream calls and include it in error responses. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small Team / MVP | URL Versioning (/v1) | Simplest to implement and understand for consumers. | Low |
| Enterprise / High Scale | Header Versioning (Accept: application/vnd.api+json;v=2) | Keeps URLs clean; allows more granular versioning. | Medium |
| High Traffic API | Redis Rate Limiting | In-memory limiters fail in clustered deployments; Redis ensures consistency. | High (Infrastructure) |
| Complex Validation | Zod + zod-to-openapi | Provides runtime safety, type inference, and auto-docs from single source of truth. | Low |
| Public API | API Keys + JWT | API keys for quota management; JWT for user context. Separation of concerns. | Medium |
Configuration Template
Copy this server.ts skeleton to bootstrap a compliant API structure.
// src/server.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import { attachEnvelope } from './middleware/envelope';
import { globalLimiter, authLimiter } from './middleware/rate-limit';
import { authenticate } from './middleware/auth';
import { requestId } from 'express-request-id';
import routes from './routes';
const app = express();
// 1. Security & Infrastructure
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', credentials: true }));
app.use(compression());
app.use(express.json({ limit: '1mb' }));
// 2. Core Middleware
app.use(requestId());
app.use(attachEnvelope);
app.use(globalLimiter);
// 3. Routes
app.use('/api/v1', authenticate, routes);
// 4. Global Error Handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(`[${req.id}] Unhandled Error:`, err);
res.envelope.failure('INTERNAL_ERROR', 'An unexpected error occurred', [], 500);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`API listening on port ${PORT}`);
});
Quick Start Guide
-
Initialize Project:
mkdir api-project && cd api-project
npm init -y
npm install express zod jsonwebtoken helmet cors compression express-rate-limit rate-limit-redis redis express-request-id
npm install -D typescript @types/express @types/node ts-node nodemon
npx tsc --init
-
Create Envelope Middleware:
Save the attachEnvelope code from Section 1 to src/middleware/envelope.ts.
-
Define a Schema and Route:
Create src/routes/accounts.ts with a Zod schema and a POST handler using validate and res.envelope.success.
-
Run the Server:
Add "start": "ts-node src/server.ts" to package.json scripts. Run npm start. Test with curl -X POST http://localhost:3000/api/v1/accounts -H "Content-Type: application/json" -d '{"contact_address":"test@example.com","display_name":"Test"}'.
-
Verify Response:
Confirm the response matches the envelope structure:
{
"result": { "id": "...", "contact_address": "test@example.com" },
"metadata": { "created_at": "..." }
}