🎛️ Controller Pattern Implementation
Once your routes are modular, the next question is where the logic lives. Stuffing database calls and business rules directly into route callbacks makes them long, duplicated, and painful to test. The Controller Pattern splits the two: routes say what the API exposes, controllers own how each request is handled. This lesson builds a clean layered architecture — routes, controllers, and an optional service layer — with modern async error handling.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain the Controller Pattern and how it separates routing from logic
- Implement a resource controller with full CRUD methods
- Handle async errors cleanly with a
catchAsyncwrapper and a central error handler - Introduce a service layer to isolate business logic from HTTP concerns
- Reduce repetition with a controller factory
- Write isolated unit tests for a controller by mocking its dependencies
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Extract inline route logic into a controller + service, with shared error handling.
In This Lesson
What Is the Controller Pattern?
The Controller Pattern separates route definitions (which URLs and methods exist) from their implementation logic (what actually happens). Routes become a thin map of endpoints to functions; those functions — the controllers — hold the request-handling code.
💡 Analogy: An Express app is a restaurant. Routes are the menu customers read — a list of what's available. Controllers are the chefs who know how to cook each dish. The menu can be reprinted without touching the recipes, and chefs can refine recipes without reprinting the menu.
- Routes — declare endpoints and wire in middleware.
- Controllers — parse the request, coordinate work, shape the response.
- Models / Services — data access and business rules.
Why Separate Routes from Logic
The split pays off across the whole lifecycle of an app:
| Benefit | What it buys you |
|---|---|
| Separation of concerns | Routes handle URL/method binding; controllers handle logic — each changes independently. |
| Testability | Controllers can be unit-tested without spinning up an HTTP server. |
| Reusability | The same controller method can back multiple routes or API versions. |
| Readability | Route files become a scannable table of contents for the API. |
| Scalability | Teams work on different controllers without colliding in one file. |
🌍 Real-world: Large platforms keep API controllers (HTTP handling) separate from service classes (inventory, payments, fulfillment) so the same business logic backs the web UI, mobile app, and public API without divergence.
A Basic Controller
Start by moving each route's callback into a named method on a controller object. The route file shrinks to a clean mapping.
project/
├── app.js
├── routes/user.routes.js
├── controllers/user.controller.js
└── models/user.model.js
// controllers/user.controller.js
const User = require('../models/user.model');
const userController = {
getAllUsers: async (req, res) => {
try {
const users = await User.find();
res.status(200).json({ data: users });
} catch (err) {
res.status(500).json({ error: err.message });
}
},
getUserById: async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.status(200).json({ data: user });
} catch (err) {
res.status(500).json({ error: err.message });
}
},
createUser: async (req, res) => {
try {
const user = await User.create(req.body);
res.status(201).json({ data: user });
} catch (err) {
res.status(400).json({ error: err.message });
}
},
updateUser: async (req, res) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id, req.body, { new: true, runValidators: true });
if (!user) return res.status(404).json({ error: 'User not found' });
res.status(200).json({ data: user });
} catch (err) {
res.status(400).json({ error: err.message });
}
},
deleteUser: async (req, res) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.status(204).end();
} catch (err) {
res.status(500).json({ error: err.message });
}
},
};
module.exports = userController;
// routes/user.routes.js — now just a map
const router = require('express').Router();
const c = require('../controllers/user.controller');
router.get('/', c.getAllUsers);
router.get('/:id', c.getUserById);
router.post('/', c.createUser);
router.put('/:id', c.updateUser);
router.delete('/:id', c.deleteUser);
module.exports = router;
📖 RESTful naming convention
| Method | Path | Controller |
|---|---|---|
| GET | /users | getAllUsers |
| GET | /users/:id | getUserById |
| POST | /users | createUser |
| PUT / PATCH | /users/:id | updateUser |
| DELETE | /users/:id | deleteUser |
Async Error Handling
Notice the repeated try/catch in every method above — noisy and easy to get wrong. In Express 4, an error thrown in an async handler is not caught automatically; you must forward it to next(). A small wrapper removes the boilerplate.
⚠️ Express 4 vs 5 on async errors
In Express 4 a rejected promise in a handler will hang unless you catch it and call next(err). Express 5 automatically forwards rejected promises from async handlers to the error middleware — but the catchAsync pattern below is still the portable, explicit choice that works in both.
// utils/catchAsync.js
const catchAsync = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = catchAsync;
// utils/AppError.js — a semantic operational error
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = String(statusCode).startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// controllers/user.controller.js — no try/catch noise
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');
const User = require('../models/user.model');
exports.getUserById = catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) return next(new AppError('User not found', 404));
res.status(200).json({ data: user });
});
exports.getAllUsers = catchAsync(async (req, res) => {
const users = await User.find();
res.status(200).json({ data: users });
});
// app.js — one central error handler, registered LAST
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
status: err.status || 'error',
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
});
💡 Analogy: Central error handling is a factory's safety system. When a machine (DB call) fails, the system detects it, halts that operation, and sends one clear alert to the control room (the client) — instead of every station inventing its own alarm.
The Service Layer
For non-trivial apps, add a service layer between controllers and models. Controllers then only deal with HTTP (reading the request, choosing a status, shaping the response), while services own the business logic and data access. The same service can back an API, a CLI, or a background job.
// services/user.service.js
const User = require('../models/user.model');
exports.getAllUsers = () => User.find();
exports.getUserById = (id) => User.findById(id);
exports.createUser = (data) => User.create(data);
// Business logic lives here, not in the controller
exports.activateUser = async (id) => {
const user = await User.findById(id);
if (!user) throw new Error('User not found');
user.active = true;
user.activatedAt = new Date();
return user.save();
};
// controllers/user.controller.js — thin HTTP glue
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');
const userService = require('../services/user.service');
exports.getAllUsers = catchAsync(async (req, res) => {
const users = await userService.getAllUsers();
res.status(200).json({ data: users });
});
exports.activateUser = catchAsync(async (req, res, next) => {
const user = await userService.activateUser(req.params.id);
res.status(200).json({ message: 'User activated', data: user });
});
💡 When do you actually need a service layer?
Skip it for simple CRUD — a controller calling a model directly is fine. Add it once business logic starts appearing in multiple controllers, or a single action touches several models. Don't add layers preemptively.
Controller Factories
Most resources share the same CRUD skeleton. A factory generates those methods for any model, so you write them once and only hand-author the resource-specific extras.
// utils/controllerFactory.js
const catchAsync = require('./catchAsync');
const AppError = require('./AppError');
exports.getAll = (Model) => catchAsync(async (req, res) => {
const docs = await Model.find();
res.status(200).json({ data: docs });
});
exports.getOne = (Model) => catchAsync(async (req, res, next) => {
const doc = await Model.findById(req.params.id);
if (!doc) return next(new AppError('Not found', 404));
res.status(200).json({ data: doc });
});
exports.createOne = (Model) => catchAsync(async (req, res) => {
const doc = await Model.create(req.body);
res.status(201).json({ data: doc });
});
exports.deleteOne = (Model) => catchAsync(async (req, res, next) => {
const doc = await Model.findByIdAndDelete(req.params.id);
if (!doc) return next(new AppError('Not found', 404));
res.status(204).end();
});
// controllers/user.controller.js
const factory = require('../utils/controllerFactory');
const User = require('../models/user.model');
const catchAsync = require('../utils/catchAsync');
exports.getAllUsers = factory.getAll(User);
exports.getUser = factory.getOne(User);
exports.createUser = factory.createOne(User);
exports.deleteUser = factory.deleteOne(User);
// Resource-specific method, hand-written
exports.search = catchAsync(async (req, res) => {
const { q } = req.query;
const users = await User.find({
$or: [
{ name: { $regex: q, $options: 'i' } },
{ email: { $regex: q, $options: 'i' } },
],
});
res.status(200).json({ data: users });
});
🌍 Real-world: The NestJS framework (built on Express) formalizes this with class-based controllers and dependency injection — the same idea, letting teams reuse CRUD scaffolding while keeping routes and logic cleanly separated.
Testing Controllers
Because controllers no longer touch the HTTP server directly, you can unit-test them by mocking the service and passing fake req/res/next objects — fast tests with no database.
// user.controller.test.js (Jest)
const userController = require('../controllers/user.controller');
const userService = require('../services/user.service');
jest.mock('../services/user.service');
describe('getAllUsers', () => {
it('responds 200 with the users', async () => {
const req = {};
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
const next = jest.fn();
userService.getAllUsers.mockResolvedValue([{ id: 1, name: 'Ada' }]);
await userController.getAllUsers(req, res, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ data: [{ id: 1, name: 'Ada' }] });
expect(next).not.toHaveBeenCalled();
});
it('forwards service errors to next()', async () => {
const req = {};
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
const next = jest.fn();
const err = new Error('boom');
userService.getAllUsers.mockRejectedValue(err);
await userController.getAllUsers(req, res, next);
expect(next).toHaveBeenCalledWith(err);
});
});
For end-to-end confidence, add integration tests with Supertest that drive the real routes and assert on real HTTP responses. Use both: unit tests for logic branches, integration tests for the wiring.
💡 Analogy: Unit tests check each machine (controller method) in isolation with simulated inputs; integration tests run the whole assembly line from raw materials (HTTP request) to finished product (HTTP response).
Hands-on Exercise
🏋️ Extract Logic into a Controller + Service
Objective: Refactor an inline route into a layered structure with shared async error handling.
Starting point (all logic inline):
app.get('/tasks/:id', async (req, res) => {
const task = tasks.find(t => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
res.json({ data: task });
});
Instructions:
- Create
utils/catchAsync.jsandutils/AppError.js. - Create
services/task.service.jswithgetTaskById(id)returning the task ornull. - Create
controllers/task.controller.jswithgetTaskwrapped incatchAsync, usingAppErrorfor the 404. - Create
routes/task.routes.jsmappingGET /:idto the controller. - Register a central error handler in
app.jsand confirm a missing id returns a clean 404.
💡 Hint
The controller should contain no try/catch — catchAsync forwards rejections to next, and the central handler turns your AppError into the JSON response. Register that handler last, after all routes.
✅ Solution
// utils/catchAsync.js
module.exports = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// utils/AppError.js
module.exports = class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = String(statusCode).startsWith('4') ? 'fail' : 'error';
}
};
// services/task.service.js
const tasks = [{ id: 1, title: 'Write tests' }];
exports.getTaskById = async (id) =>
tasks.find(t => t.id === Number(id)) ?? null;
// controllers/task.controller.js
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');
const taskService = require('../services/task.service');
exports.getTask = catchAsync(async (req, res, next) => {
const task = await taskService.getTaskById(req.params.id);
if (!task) return next(new AppError('Task not found', 404));
res.status(200).json({ data: task });
});
// routes/task.routes.js
const router = require('express').Router();
const c = require('../controllers/task.controller');
router.get('/:id', c.getTask);
module.exports = router;
// app.js
const express = require('express');
const app = express();
app.use('/tasks', require('./routes/task.routes'));
app.use((err, req, res, next) => {
res.status(err.statusCode || 500)
.json({ status: err.status || 'error', message: err.message });
});
app.listen(3000);
🎯 Quick Quiz
Question 1: In the Controller Pattern, what is a route file mainly responsible for?
Question 2: What problem does a catchAsync wrapper solve?
Question 3: Where should the central error-handling middleware be registered?
Best Practices
| ✅ Do | ❌ Avoid |
|---|---|
| Keep controllers focused on HTTP: parse request, pick status, shape response | Burying business logic and raw DB calls in route callbacks |
Forward errors with next(err) and handle them centrally | Repeating try/catch and ad-hoc error JSON in every method |
Use consistent RESTful method names (getAllUsers, createUser) | Inconsistent names that obscure what each does |
| Add a service layer only when logic is shared or complex | Layering everything preemptively for a simple CRUD app |
| Unit-test controllers with mocks; integration-test the wiring | Relying only on manual clicking to verify behavior |
Summary & Quiz
🎉 Key Takeaways
- The Controller Pattern makes routes a thin map and puts logic in controllers.
- A
catchAsyncwrapper plus a central error handler removes repetitive try/catch. - Express 5 auto-forwards async rejections, but
catchAsyncstays portable across versions. - A service layer isolates business logic — add it when logic is shared or complex, not by default.
- Controller factories generate CRUD boilerplate for any model.
- Thin controllers are easy to unit-test with mocked dependencies.
📚 Further Reading
- Express — Error handling (async & central handler)
- Node.js Best Practices — project structure & error handling
- NestJS — Controllers (the pattern, formalized)
🚀 What's Next?
With routes, routers, and controllers in place, you have the skeleton of a real backend. Next we'll assemble these pieces into a full RESTful API with Express — designing resources, status codes, and endpoints end to end.
🎉 Clean architecture achieved!
Your logic is testable, reusable, and out of the route files. On to building a full REST API.