Controllers
The traffic cops of your API - receiving requests, coordinating responses, but never doing the heavy lifting.
The Waiter Analogy#
A restaurant waiter takes your order, brings it to the kitchen, and delivers your food. They don't cook. They don't decide recipes. They coordinate between you and the chef.
Controllers are your API's waiters:
- Receive the request (take the order)
- Call the right service (tell the kitchen)
- Send the response (deliver the food)
That's it. Controllers should be thin.
What Controllers Do#
Controllers handle the HTTP layer:
- Extract data from requests (params, query, body, headers)
- Call services with that data
- Format responses (status codes, JSON structure)
- Handle HTTP errors (404, 401, 403)
What they DON'T do:
- Database queries
- Business logic
- External API calls
- Heavy computation
Basic Controller Structure#
// src/controllers/users.js
import * as userService from '../services/users.js';
import { NotFoundError, ForbiddenError } from '../utils/errors.js';
export async function list(req, res) {
const { page = 1, limit = 20 } = req.query;
const result = await userService.findAll({
page: Number(page),
limit: Number(limit),
});
res.json({
data: result.users,
pagination: {
page: Number(page),
limit: Number(limit),
total: result.total,
pages: Math.ceil(result.total / limit),
},
});
}
export async function get(req, res) {
const user = await userService.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json({ data: user });
}
export async function create(req, res) {
const user = await userService.create(req.body);
res.status(201).json({ data: user });
}
export async function update(req, res) {
// Authorization: can only update yourself (unless admin)
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
throw new ForbiddenError('Cannot update other users');
}
const user = await userService.update(req.params.id, req.body);
if (!user) {
throw new NotFoundError('User');
}
res.json({ data: user });
}
export async function remove(req, res) {
await userService.remove(req.params.id);
res.status(204).send();
}
The Request Object#
Express gives you everything about the request:
export async function example(req, res) {
// URL parameters (/users/:id)
const { id } = req.params;
// Query string (/users?page=2&sort=name)
const { page, sort, filter } = req.query;
// Request body (JSON)
const { email, password } = req.body;
// Headers
const token = req.headers.authorization;
const contentType = req.headers['content-type'];
// User (added by auth middleware)
const currentUser = req.user;
// Other useful properties
req.method; // 'GET', 'POST', etc.
req.path; // '/users/123'
req.ip; // Client IP
req.hostname; // 'api.example.com'
}
Response Helpers#
Keep responses consistent with helpers:
// src/utils/response.js
export function success(res, data, statusCode = 200) {
res.status(statusCode).json({ data });
}
export function created(res, data) {
res.status(201).json({ data });
}
export function paginated(res, data, pagination) {
res.json({ data, pagination });
}
export function noContent(res) {
res.status(204).send();
}
Now controllers are even cleaner:
import { success, created, paginated, noContent } from '../utils/response.js';
export async function list(req, res) {
const { page = 1, limit = 20 } = req.query;
const result = await userService.findAll({ page: Number(page), limit: Number(limit) });
paginated(res, result.users, {
page: Number(page),
limit: Number(limit),
total: result.total,
});
}
export async function get(req, res) {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User');
success(res, user);
}
export async function create(req, res) {
const user = await userService.create(req.body);
created(res, user);
}
export async function remove(req, res) {
await userService.remove(req.params.id);
noContent(res);
}
Error Handling#
Don't try-catch in every controller. Use custom errors and let middleware handle them:
// src/utils/errors.js
export class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409, 'CONFLICT');
}
}
// Controller just throws
export async function get(req, res) {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // Error middleware catches this
success(res, user);
}
// src/middleware/error.js
export function errorHandler(err, req, res, next) {
// Custom errors
if (err.statusCode) {
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message }
});
}
// Unknown errors
console.error(err);
res.status(500).json({
error: { code: 'INTERNAL_ERROR', message: 'Something went wrong' }
});
}
Why No Try-Catch?
Express catches async errors automatically in v5+. For v4, use express-async-errors or wrap handlers. Either way, let errors bubble up to the error middleware.
Input Extraction Patterns#
Pagination#
export async function list(req, res) {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
const skip = (page - 1) * limit;
// ... use in query
}
Sorting#
export async function list(req, res) {
// ?sort=name,-createdAt (- for descending)
const sortFields = req.query.sort?.split(',') || ['-createdAt'];
const sort = {};
sortFields.forEach(field => {
if (field.startsWith('-')) {
sort[field.slice(1)] = -1;
} else {
sort[field] = 1;
}
});
// sort = { name: 1, createdAt: -1 }
}
Filtering#
export async function list(req, res) {
const { status, role, search } = req.query;
const filters = {};
if (status) filters.status = status;
if (role) filters.role = role;
if (search) filters.name = { $regex: search, $options: 'i' };
const users = await userService.findAll({ filters, page, limit });
}
Controller Patterns#
Resource Controller (CRUD)#
Standard pattern for database resources:
// GET /users → list
// GET /users/:id → get
// POST /users → create
// PATCH /users/:id → update
// DELETE /users/:id → remove
export const list = async (req, res) => { ... };
export const get = async (req, res) => { ... };
export const create = async (req, res) => { ... };
export const update = async (req, res) => { ... };
export const remove = async (req, res) => { ... };
Action Controller#
For non-CRUD operations:
// src/controllers/auth.js
export async function login(req, res) {
const { email, password } = req.body;
const result = await authService.login(email, password);
success(res, result);
}
export async function logout(req, res) {
await authService.logout(req.user.id);
noContent(res);
}
export async function refreshToken(req, res) {
const { refreshToken } = req.body;
const tokens = await authService.refresh(refreshToken);
success(res, tokens);
}
export async function forgotPassword(req, res) {
await authService.sendPasswordReset(req.body.email);
success(res, { message: 'Reset email sent' });
}
Nested Resources#
For related data:
// GET /users/:userId/orders
export async function listUserOrders(req, res) {
const { userId } = req.params;
const orders = await orderService.findByUser(userId);
success(res, orders);
}
// POST /users/:userId/orders
export async function createUserOrder(req, res) {
const { userId } = req.params;
const order = await orderService.create({ ...req.body, userId });
created(res, order);
}
Authorization in Controllers#
Controllers are a good place for authorization checks:
export async function update(req, res) {
const { id } = req.params;
// Load the resource
const post = await postService.findById(id);
if (!post) throw new NotFoundError('Post');
// Check ownership or admin
const isOwner = post.authorId === req.user.id;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
throw new ForbiddenError('Cannot edit this post');
}
// Proceed with update
const updated = await postService.update(id, req.body);
success(res, updated);
}
Or use middleware for reusable authorization:
// src/middleware/authorize.js
export function ownerOrAdmin(resourceGetter) {
return async (req, res, next) => {
const resource = await resourceGetter(req);
if (!resource) throw new NotFoundError();
const isOwner = resource.userId === req.user.id;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
throw new ForbiddenError();
}
req.resource = resource;
next();
};
}
// Usage in routes
router.patch('/:id',
authenticate,
ownerOrAdmin(req => postService.findById(req.params.id)),
postController.update
);
Testing Controllers#
Controllers are easy to test because they're thin:
// tests/controllers/users.test.js
import { jest } from '@jest/globals';
import * as userController from '../../src/controllers/users.js';
import * as userService from '../../src/services/users.js';
// Mock the service
jest.mock('../../src/services/users.js');
describe('UserController', () => {
describe('get', () => {
it('returns user when found', async () => {
const mockUser = { id: '1', name: 'Test' };
userService.findById.mockResolvedValue(mockUser);
const req = { params: { id: '1' } };
const res = { json: jest.fn() };
await userController.get(req, res);
expect(res.json).toHaveBeenCalledWith({ data: mockUser });
});
it('throws NotFoundError when user missing', async () => {
userService.findById.mockResolvedValue(null);
const req = { params: { id: '1' } };
const res = {};
await expect(userController.get(req, res)).rejects.toThrow('User not found');
});
});
});
Key Takeaways#
- Controllers are thin - They coordinate, not compute
- Extract input - params, query, body → service call
- Format output - Use response helpers for consistency
- Throw errors - Let middleware handle them
- Handle authorization - Check permissions here or in middleware
- One job per function - Each controller function handles one endpoint
The Rule
If you're doing more than extracting input, calling a service, and sending a response, you're putting too much in your controller.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.