Writing Clean API Controllers in Express
Clean controllers are small and focused. Here is how to write them.
Writing Clean API Controllers in Express
Controllers are where request handling lives. Clean controllers are small, focused, and easy to test. Here is how to write them.
The Shape of a Clean Controller
A controller function:
- Parses the request (req.body, req.params, req.query).
- Calls a service or model.
- Sends the response with the right status code.
- Handles errors (throws ApiError or passes to next).
Example: createUser
const createUser = asyncHandler(async (req, res) => {
const { firstName, email, password } = req.body;
const existing = await User.findOne({ email });
if (existing) throw new ApiError(409, 'Email already in use');
const hashed = await bcrypt.hash(password, 10);
const user = await User.create({ firstName, email, passwordHash: hashed });
res.status(201).json({ id: user.id, firstName, email });
});
Reads cleanly. Five steps: parse, check duplicate, hash, create, respond. No business magic.
Keep Controllers Thin
Do not put complex logic in controllers. Move it to a services folder. Controllers orchestrate; services do the work.
Use a Service Layer for Complex Logic
// services/userService.js
async function createUser(data) {
const existing = await User.findOne({ email: data.email });
if (existing) throw new ApiError(409, 'Email already in use');
const hashed = await bcrypt.hash(data.password, 10);
return User.create({ ...data, passwordHash: hashed });
}
// controllers/userController.js
const createUser = asyncHandler(async (req, res) => {
const user = await userService.createUser(req.body);
res.status(201).json(user);
});
Controller stays thin. Service is reusable and testable.
Pick the Right Status Code
- 200 OK for GET, PATCH.
- 201 Created for POST.
- 204 No Content for DELETE.
- 400 for validation.
- 401 for not authenticated.
- 403 for not authorized.
- 404 for not found.
- 409 for conflict.
- 500 for server error.
Do Not Return Sensitive Fields
Never return passwordHash, JWT secrets, or internal IDs the client should not see. Use a serializer:
const toUserDTO = (user) => ({
id: user.id,
firstName: user.firstName,
email: user.email,
photoUrl: user.photoUrl
});
res.status(201).json(toUserDTO(user));
Use asyncHandler
Wrap every async controller with asyncHandler so promise rejections go to the error handler. Without it, errors are swallowed and the client hangs.
One Endpoint, One Controller
Do not put two endpoints' logic in one controller. Keep functions single-purpose. createUser, getUser, updateUser are separate functions.
Test Controllers
Use Jest and supertest. Import the Express app, send requests, assert on the response. Test happy paths and error paths (validation, not found, conflict).
The Takeaway
Clean Express controllers are thin: parse the request, call a service or model, send the response with the right status code, and handle errors. Move complex logic to a services layer. Never return sensitive fields. Use asyncHandler. One endpoint, one controller function. Test with supertest.
Parse the request, call a service or model, send the response with the right status code, and handle errors with ApiError. Keep it thin. Move complex logic to a services layer. Use asyncHandler so rejections go to the error handler.
Yes for anything non-trivial. Controllers orchestrate; services do the work. Services are reusable and testable. Without them, controllers become long and hard to test.
Use a serializer (a toUserDTO function) that picks only the safe fields. Never return passwordHash, JWT secrets, or internal IDs. Apply the serializer before sending the response.
Express 4 does not catch promise rejections from async controllers. asyncHandler wraps with Promise.resolve(fn(...)).catch(next), so rejections go to the error handler. Without it, errors are swallowed and the client hangs.
Use Jest and supertest. Import the Express app, send requests with supertest, assert on the response. Test happy paths and error paths (validation, not found, conflict). Use a test database so tests do not pollute dev data.
Ready to master Node.js completely?
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.
Master Node.js
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course.

