How to Write Clean REST APIs in Express
Most Express APIs start clean and become unmaintainable within months. This guide gives you the architecture, patterns, and code to build APIs that stay clean as they scale.
Design a Scalable Layered Architecture

Understanding the Routes → Controllers → Services Pattern
The single biggest mistake developers make is dumping everything into route handlers. Business logic, database calls, and response formatting all crammed into one function. It works until it doesn't.
The pattern that actually scales: Routes map URLs to handlers. Controllers extract request data and format responses. Services hold all business logic. Each layer has one job.
// BAD: Everything in the route handler
app.get('/users/:id', async (req, res) => {
try {
const user = await db.query(`SELECT * FROM users WHERE id = ${req.params.id}`);
if (!user) return res.status(404).json({ message: 'Not found' });
res.json(user);
} catch (err) {
res.status(500).json({ message: err.message }); // leaks internals!
}
});
// GOOD: Thin route, delegating to controller
// routes/users.js
router.get('/:id', authenticate, userController.getById);
// controllers/userController.js
async getById(req, res, next) {
try {
const user = await userService.findById(req.params.id);
res.status(200).json({ success: true, data: user });
} catch (err) {
next(err); // global error handler takes over
}
}
// services/userService.js
async findById(id) {
const user = await UserRepository.findById(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
}
Structuring Your Project for Growth
Here's the directory structure that survives production traffic and team growth:
src/
├── routes/ # HTTP routing only
├── controllers/ # Request/response handling
├── services/ # Business logic
├── middleware/ # Auth, logging, rate limiting
├── validators/ # Schema validation (Zod/Joi)
├── errors/ # Custom error classes
├── utils/ # Shared helpers
├── config/ # Environment & app config
└── app.js # Express setup
Keeping Express Objects Out of Business Logic
Passing req and res into your services ties them to HTTP forever. You can't reuse them from a CLI job, a queue worker, or a unit test without spinning up Express.
// BAD: Service depends on Express internals
async function createUser(req) {
const { name, email } = req.body;
const ip = req.ip; // now your service knows about HTTP
return db.users.create({ name, email, ip });
}
// GOOD: Controller extracts data, service gets plain values
// controller
async create(req, res, next) {
try {
const { name, email } = req.body;
const user = await userService.createUser({ name, email });
res.status(201).json({ success: true, data: user });
} catch (err) { next(err); }
}
// service — zero Express knowledge
async function createUser({ name, email }) {
return db.users.create({ name, email });
}
Master REST Design Principles & HTTP Standards
Designing Proper Resource-Based Endpoints
REST uses nouns for resources and HTTP verbs for actions. This isn't just convention — clients and tooling depend on it.
| Wrong | Right | Method |
|---|---|---|
| /getMovies | /movies | GET |
| /createUser | /users | POST |
| /deleteMovie/5 | /movies/5 | DELETE |
| /movies/5/getReviews | /movies/5/reviews | GET |
| /updateUserProfile/3 | /users/3/profile | PATCH |
Keep nesting to one level deep. /users/:userId/posts is fine. /users/:userId/posts/:postId/comments/:commentId/likes is a design problem.
Mastering HTTP Status Codes & Response Formatting
Pick a response shape and use it everywhere. Inconsistent response structures break client code and waste debugging time.
// utils/response.js
const sendSuccess = (res, data, statusCode = 200) =>
res.status(statusCode).json({ success: true, data });
const sendError = (res, message, statusCode = 500) =>
res.status(statusCode).json({ success: false, error: message });
// Usage across controllers:
sendSuccess(res, users); // 200 - list fetched
sendSuccess(res, newUser, 201); // 201 - resource created
sendError(res, 'Not found', 404); // 404 - missing resource
sendError(res, 'Invalid input', 400); // 400 - bad request
sendError(res, 'Unauthorized', 401); // 401 - missing/invalid auth
sendError(res, 'Forbidden', 403); // 403 - insufficient permissions
Implementing API Versioning Strategy
URL versioning (/v1/movies) wins in practice. It's explicit, cacheable, and immediately visible in logs. Header-based versioning is elegant in theory and painful in debugging.
// app.js
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');
app.use('/v1', v1Router);
app.use('/v2', v2Router);
// routes/v1/index.js
router.use('/movies', require('./movies'));
router.use('/users', require('./users'));
Start versioning on day one. Retrofitting it into an unversioned API means coordinating breaking changes with every client simultaneously.
Build Bulletproof Security, Validation & Error Handling
Implement Enterprise-Grade Error Handling
A global error handler is non-negotiable. Without one, unhandled async errors crash your process or silently hang requests.
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(msg = 'Resource not found') { super(msg, 404); }
}
class ValidationError extends AppError {
constructor(msg) { super(msg, 400); }
}
// middleware/errorHandler.js
module.exports = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
const isOperational = err.isOperational || false;
// Log full error server-side
logger.error({ err, path: req.path, method: req.method });
// Send safe message to client
res.status(statusCode).json({
success: false,
error: isOperational ? err.message : 'An internal server error occurred',
});
};
// app.js — must be last middleware
app.use(errorHandler);
The isOperational flag is critical. It separates expected errors (user sent bad data) from programmer errors (null reference in service). Never leak stack traces to clients.
Validate Requests & Enforce Schema Contracts
Validate at the controller boundary, before business logic runs. Use Zod — it gives you TypeScript-compatible schemas and runtime validation in one package.
// validators/userValidator.js
const { z } = require('zod');
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).optional(),
});
// middleware/validate.js
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const message = result.error.errors.map(e => e.message).join(', ');
return next(new ValidationError(message));
}
req.validated = result.data; // use this in controller, not req.body
next();
};
// routes/users.js
router.post('/', validate(createUserSchema), userController.create);
Secure Your API with Authentication & Authorization
JWT middleware runs before your controllers and attaches the decoded user to the request. Keep it simple and stateless.
// middleware/authenticate.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return next(new AppError('No token provided', 401));
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
next(new AppError('Invalid or expired token', 401));
}
};
// middleware/authorize.js — RBAC
const authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role))
return next(new AppError('Insufficient permissions', 403));
next();
};
// Usage
router.delete('/:id', authenticate, authorize('admin'), userController.remove);
Add rate limiting with express-rate-limit at the app level and tighten it on auth endpoints. Set CORS explicitly — never * in production. See the Express security best practices docs for header configuration with Helmet.
Testing, Documentation & Production Readiness

Test REST APIs at Every Level
Services are pure functions — unit test them. Routes need integration tests with Supertest. Both are cheap to write if your architecture is clean.
// Unit test — service layer
describe('userService.findById', () => {
it('throws NotFoundError when user missing', async () => {
UserRepository.findById = jest.fn().mockResolvedValue(null);
await expect(userService.findById('999')).rejects.toThrow(NotFoundError);
});
});
// Integration test — full HTTP cycle
const request = require('supertest');
const app = require('../app');
describe('GET /v1/users/:id', () => {
it('returns 404 for unknown user', async () => {
const res = await request(app).get('/v1/users/9999');
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
});
});
Document APIs & Handle Production Concerns
Set up swagger-ui-express with an OpenAPI 3.0 spec. Your spec becomes the contract between frontend and backend — disputes end when you have a canonical source of truth.
For logging, use pino over winston in 2026 — it's faster and outputs structured JSON natively. Log every inbound request, every error, and slow queries over 200ms. Add a health check endpoint so your load balancer and monitoring tools can verify the app is alive:
// routes/health.js
router.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
Validate environment variables at startup with Zod. If JWT_SECRET is missing, crash immediately rather than failing at runtime on the first authenticated request. For more on structuring Node.js projects for production, check out our guide on scalable Node.js project structure.
Frequently Asked Questions
Q: Should I use async/await everywhere or stick with callbacks?
A: Async/await everywhere. Callbacks make error propagation to your global handler nearly impossible. Wrap your async route handlers or use a library like express-async-errors to automatically catch rejections and forward them to next(err).
Q: Where should I put input validation — middleware or controller?
A: Validation middleware, chained before the controller in the route definition. It keeps controllers thin, makes validation reusable across routes, and lets you see the full request contract directly in the route file.
Q: Is the controller/service pattern overkill for small APIs?
A: Small APIs don't stay small. The pattern costs you ten minutes to set up and saves hours when you need to add caching, switch databases, or write tests. Start with it from day one — collapsing layers is always easier than adding them later. Also see our post on Express middleware patterns for keeping smaller apps organized without over-engineering.
Wrap-up
Clean Express APIs come down to four things: layered architecture that separates concerns, RESTful design that respects HTTP semantics, error handling and validation that never leaks internals, and tests that prove it all works. None of these are optional in production.
Start by refactoring one existing route into the controller/service pattern — the improvement in clarity will make the rest of the migration obvious.
References
- Learn REST API Principles by Building an Express App
- How to make a clean architecture for RestAPI on NodeJs
- Project structure for an Express REST API when there is no "standard way" – Corey Cleary
- Building REST APIs with Express.js: A Comprehensive Guide
- Creating a REST API with Node.js and Express - Postman Blog
- 10 Common Mistakes When Building REST APIs (and How to Avoid ...
Comments
Post a Comment