Building APIs is the bread and butter of backend development, but a poorly designed API turns into a maintenance nightmare the moment traffic scales. After shipping more than 200 endpoints across production SaaS platforms, I have distilled the patterns that consistently prevent regressions and keep codebases approachable for every developer on the team.
Project Structure That Scales
The single biggest lever for long-term maintainability is a consistent folder layout. I use a feature-based structure rather than the classic MVC split:
src/
├── modules/
│ ├── users/
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── users.routes.ts
│ │ ├── users.validation.ts
│ │ └── users.types.ts
│ └── orders/
│ ├── orders.controller.ts
│ └── ...
├── middleware/
│ ├── auth.ts
│ ├── errorHandler.ts
│ └── rateLimiter.ts
├── config/
│ └── db.ts
└── app.tsEvery feature lives in its own directory with its own controller, service, routes, and validation schemas. When a new developer joins, they can reason about the users module without understanding the entire codebase.
Request Validation with Zod
Never trust client input. I validate every request body, query parameter, and URL parameter at the route level with Zod:
import { z } from 'zod';
export const createUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(2, 'Name must be at least 2 characters'),
password: z.string().min(8, 'Password must be at least 8 characters'),
}),
});
export type CreateUserInput = z.infer<typeof createUserSchema>['body'];A generic validation middleware applies any Zod schema to the incoming request:
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
export const validate =
(schema: AnyZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
status: 'error',
errors: err.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
});
}
next(err);
}
};The validation middleware is then attached to routes declaratively:
router.post('/users', validate(createUserSchema), usersController.create);Centralized Error Handling
Scattered try/catch blocks create inconsistent responses. Instead, I wrap every controller action with an async handler and funnel errors into a single error middleware:
// asyncHandler.ts
import { Request, Response, NextFunction } from 'express';
export const asyncHandler =
(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) =>
(req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next);// errorHandler.ts
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
export const errorHandler = (
err: AppError | Error,
_req: Request,
res: Response,
_next: NextFunction
) => {
const statusCode = 'statusCode' in err ? err.statusCode : 500;
res.status(statusCode).json({
status: 'error',
message: err.message || 'Internal Server Error',
});
};API Versioning
Prefix every route group with a version tag (/api/v1/...). When a breaking change is required, spin up /api/v2/ and deprecate the old version on a timeline:
import v1Routes from './routes/v1';
import v2Routes from './routes/v2';
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);This approach lets mobile clients that cannot force-update continue operating against v1 while web clients adopt v2 immediately.
Rate Limiting and Security Headers
Apply rate limiting early in the middleware chain. I use the express-rate-limit package combined with a Redis store for multi-instance deployments:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redisClient } from './config/redis';
const limiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per window
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);Add helmet for security headers and cors to whitelist trusted origins — two lines that harden an API surface dramatically.
Consistent Response Envelopes
Wrap every response in a predictable envelope so frontend teams never guess the shape:
res.status(200).json({
status: 'success',
data: { user },
meta: { page: 1, limit: 20, total: 58 },
});Key Takeaways
- Validate early — reject bad requests before they touch your database.
- Handle errors centrally — one handler, one response shape.
- Version your APIs — protect existing consumers while evolving.
- Rate-limit everything — defend against abuse and runaway clients.
- Structure by feature — keep cognitive load proportional to the task.
These patterns have survived traffic spikes, midnight on-call incidents, and multi-team handoffs. Adopt them early, and your future self will be grateful.
