Introduction to the Controller Pattern
The Controller Pattern is a design pattern that separates route definitions (URLs and HTTP methods) from their implementation logic. This separation helps create a more maintainable and testable application architecture.
Analogy: Think of an Express application like a restaurant. Routes are like the menu that customers see, showing what's available. Controllers are like the chefs who know how to prepare each dish. The menu (routes) simply lists the dishes, while the detailed recipes and cooking techniques (business logic) belong to the chefs (controllers). This separation allows the menu to change without affecting how dishes are prepared, and lets chefs refine their recipes without requiring menu updates.
In this pattern:
- Routes define the endpoints (URLs and HTTP methods) that the application responds to
- Controllers contain the logic that processes requests and formulates responses
- Models/Services handle data access and business rules
Benefits of the Controller Pattern
Separating routes from controllers offers several significant advantages:
Separation of Concerns
The controller pattern divides responsibilities clearly:
- Routes handle URL mapping and HTTP method binding
- Controllers handle request processing logic
- Services handle business logic
- Models handle data access and storage
Code Organization
With controllers, related functionality is grouped together regardless of URL structure, making it easier to locate and maintain specific features.
Testability
Controllers can be tested independently of the HTTP layer, allowing for more focused unit tests.
Reusability
The same controller functions can be used by different routes, reducing code duplication.
Maintainability
Changes to business logic don't require changes to route definitions, and vice versa.
Scalability
As an application grows, the controller pattern helps manage complexity by maintaining a clear structure.
Real-world example: The e-commerce platform Shopify uses the controller pattern in their backend architecture. Their routes define the API endpoints for products, customers, orders, etc., while separate controller modules handle the complex logic of inventory management, payment processing, and order fulfillment. This separation allows their development teams to work on specific features without interfering with each other.
Basic Controller Implementation
Let's start with a simple controller implementation for a user resource:
Project Structure
project/
├── app.js # Main application file
├── routes/
│ └── user.routes.js # User routes
├── controllers/
│ └── user.controller.js # User controller
├── models/
│ └── user.model.js # User model
└── middleware/
└── auth.js # Authentication middleware
User Controller
// controllers/user.controller.js
const User = require('../models/user.model');
// Controller object with methods for each route handler
const userController = {
// Get all users
getAllUsers: async (req, res) => {
try {
const users = await User.find();
res.status(200).json({ data: users });
} catch (error) {
res.status(500).json({ error: error.message });
}
},
// Get a single user by ID
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 (error) {
res.status(500).json({ error: error.message });
}
},
// Create a new user
createUser: async (req, res) => {
try {
const newUser = await User.create(req.body);
res.status(201).json({ data: newUser });
} catch (error) {
res.status(400).json({ error: error.message });
}
},
// Update a user
updateUser: async (req, res) => {
try {
const updatedUser = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!updatedUser) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json({ data: updatedUser });
} catch (error) {
res.status(400).json({ error: error.message });
}
},
// Delete a user
deleteUser: async (req, res) => {
try {
const deletedUser = await User.findByIdAndDelete(req.params.id);
if (!deletedUser) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json({ message: 'User deleted successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
}
};
module.exports = userController;
User Routes
// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
// Map routes to controller methods
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', userController.createUser);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);
module.exports = router;
Main Application
// app.js
const express = require('express');
const userRoutes = require('./routes/user.routes');
const app = express();
// Middleware
app.use(express.json());
// Routes
app.use('/api/users', userRoutes);
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
This basic implementation demonstrates the separation of routes and controllers. The routes define the URL structure, while the controllers contain the logic for handling requests.
Analogy: The controller methods are like specialized chefs in a kitchen, each with a specific dish they know how to prepare. When a customer (client) places an order (makes an HTTP request), the waiter (route) knows which chef (controller method) is responsible for preparing that dish and delivers the order to them.
Controller Organization Strategies
There are several ways to organize controllers in an Express application:
Resource-Based Controllers
Organize controllers by resource type, with one controller per resource:
// controllers/user.controller.js
// controllers/product.controller.js
// controllers/order.controller.js
Feature-Based Controllers
Organize controllers by application feature or domain:
// controllers/auth.controller.js
// controllers/admin.controller.js
// controllers/public.controller.js
Action-Based Controllers
Organize controllers by action type:
// controllers/create.controller.js
// controllers/read.controller.js
// controllers/update.controller.js
// controllers/delete.controller.js
Choosing an Organization Strategy
The resource-based approach is most common in RESTful APIs and provides the clearest organization for most applications. However, the best strategy depends on your specific application needs:
| Strategy | Best For | Considerations |
|---|---|---|
| Resource-based | RESTful APIs, CRUD operations | Clear mapping to database models |
| Feature-based | Complex applications with distinct feature sets | May involve multiple resources per feature |
| Action-based | Applications with complex workflows | Less common, can be harder to navigate |
Real-world example: The popular Node.js CMS Strapi uses resource-based controllers for their content types. Each content type (article, product, category, etc.) has its own controller with standard CRUD methods, making it easy for developers to understand how to interact with different types of content through the API.
Controller Method Naming Conventions
Consistent naming of controller methods helps maintain a clear, understandable codebase. Here are common naming conventions for RESTful controllers:
| HTTP Method | URL Pattern | Controller Method | Purpose |
|---|---|---|---|
| GET | /resources | getAll[Resources] | List all resources |
| GET | /resources/:id | get[Resource] | Get a single resource |
| POST | /resources | create[Resource] | Create a new resource |
| PUT | /resources/:id | update[Resource] | Replace a resource |
| PATCH | /resources/:id | update[Resource] | Partially update a resource |
| DELETE | /resources/:id | delete[Resource] | Delete a resource |
Additional Method Naming Patterns
For non-standard operations, consistent naming is still important:
// Search functionality
searchUsers: async (req, res) => {
// Implementation...
}
// Complex actions
activateUser: async (req, res) => {
// Implementation...
}
deactivateUser: async (req, res) => {
// Implementation...
}
// Nested resources
getUserPosts: async (req, res) => {
// Implementation...
}
createUserPost: async (req, res) => {
// Implementation...
}
Error Handling in Controllers
Proper error handling is crucial in controllers to ensure robust application behavior and meaningful error responses.
Try/Catch Blocks
Since controllers often contain asynchronous operations, try/catch blocks are essential:
// Basic error handling with try/catch
const getUser = 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 (error) {
res.status(500).json({ error: error.message });
}
};
Centralized Error Handling
For more advanced applications, consider using a centralized error handling approach:
// Create custom error classes
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// Controller with centralized error handling
const getUser = async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
// Create a custom error and pass it to the error handler
return next(new AppError('User not found', 404));
}
res.status(200).json({ data: user });
} catch (error) {
next(error);
}
};
// Global error handler middleware
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
res.status(err.statusCode).json({
status: err.status,
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
Async Wrapper Function
To reduce repetitive try/catch blocks, you can create an async wrapper function:
// Async wrapper utility
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
// Simplified controller method using the wrapper
const getUser = 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 });
});
// Apply to all controller methods
const getAllUsers = catchAsync(async (req, res) => {
const users = await User.find();
res.status(200).json({ data: users });
});
const createUser = catchAsync(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ data: user });
});
Analogy: Error handling in controllers is like a safety system in a factory. When a machine (database operation) malfunctions, the safety system detects the problem, shuts down the affected area (aborts the operation), and sends a clear alert to the control center (client) with information about what went wrong and how serious it is. This prevents cascading failures and helps engineers diagnose and fix the issue quickly.
Service Layer Pattern
For complex applications, it's often beneficial to add a service layer between controllers and models. This layer encapsulates business logic, making controllers focused solely on HTTP request/response handling.
Project Structure with Services
project/
├── app.js
├── routes/
│ └── user.routes.js
├── controllers/
│ └── user.controller.js
├── services/
│ └── user.service.js
├── models/
│ └── user.model.js
└── middleware/
└── auth.js
User Service
// services/user.service.js
const User = require('../models/user.model');
const userService = {
getAllUsers: async () => {
return await User.find();
},
getUserById: async (userId) => {
return await User.findById(userId);
},
createUser: async (userData) => {
return await User.create(userData);
},
updateUser: async (userId, userData) => {
return await User.findByIdAndUpdate(userId, userData, {
new: true,
runValidators: true
});
},
deleteUser: async (userId) => {
return await User.findByIdAndDelete(userId);
},
// Business logic methods
activateUser: async (userId) => {
const user = await User.findById(userId);
if (!user) {
throw new Error('User not found');
}
user.active = true;
user.activatedAt = new Date();
return await user.save();
},
deactivateUser: async (userId) => {
const user = await User.findById(userId);
if (!user) {
throw new Error('User not found');
}
user.active = false;
user.deactivatedAt = new Date();
return await user.save();
}
};
module.exports = userService;
Controller Using Service Layer
// controllers/user.controller.js
const userService = require('../services/user.service');
const userController = {
getAllUsers: async (req, res, next) => {
try {
const users = await userService.getAllUsers();
res.status(200).json({ data: users });
} catch (error) {
next(error);
}
},
getUserById: async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json({ data: user });
} catch (error) {
next(error);
}
},
// Other CRUD methods...
// Controller for activation
activateUser: async (req, res, next) => {
try {
const user = await userService.activateUser(req.params.id);
res.status(200).json({
message: 'User activated successfully',
data: user
});
} catch (error) {
next(error);
}
},
// Controller for deactivation
deactivateUser: async (req, res, next) => {
try {
const user = await userService.deactivateUser(req.params.id);
res.status(200).json({
message: 'User deactivated successfully',
data: user
});
} catch (error) {
next(error);
}
}
};
module.exports = userController;
Real-world example: Enterprise applications like Salesforce use a service layer pattern in their architecture. Their CRM system separates API controllers (which handle HTTP requests) from service classes that implement complex business logic like opportunity tracking, lead scoring, and account management. This allows them to reuse the same business logic across multiple interfaces (web UI, mobile app, API) while maintaining consistent behavior.
Controller Testing
The controller pattern facilitates effective testing by isolating HTTP handling from business logic.
Unit Testing Controllers
When using a service layer, controllers can be unit tested by mocking the service:
// Test file: controllers/user.controller.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const userController = require('../controllers/user.controller');
const userService = require('../services/user.service');
describe('User Controller', () => {
describe('getAllUsers', () => {
it('should return all users with status 200', async () => {
// Arrange
const req = {};
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
const next = sinon.spy();
const users = [{ id: 1, name: 'Test User' }];
sinon.stub(userService, 'getAllUsers').resolves(users);
// Act
await userController.getAllUsers(req, res, next);
// Assert
expect(res.status.calledWith(200)).to.be.true;
expect(res.json.calledWith({ data: users })).to.be.true;
expect(next.called).to.be.false;
// Cleanup
userService.getAllUsers.restore();
});
it('should call next with error if service throws', async () => {
// Arrange
const req = {};
const res = {
status: sinon.stub().returnsThis(),
json: sinon.spy()
};
const next = sinon.spy();
const error = new Error('Service error');
sinon.stub(userService, 'getAllUsers').rejects(error);
// Act
await userController.getAllUsers(req, res, next);
// Assert
expect(next.calledWith(error)).to.be.true;
expect(res.status.called).to.be.false;
// Cleanup
userService.getAllUsers.restore();
});
});
// More test cases for other controller methods...
});
Integration Testing
Integration tests verify that controllers work correctly with actual HTTP requests:
// Test file: integration/user.routes.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
const User = require('../models/user.model');
describe('User Routes', () => {
beforeEach(async () => {
// Clear users collection before each test
await User.deleteMany({});
});
describe('GET /api/users', () => {
it('should return all users', async () => {
// Arrange
await User.create([
{ name: 'User 1', email: 'user1@example.com' },
{ name: 'User 2', email: 'user2@example.com' }
]);
// Act
const res = await request(app)
.get('/api/users')
.expect('Content-Type', /json/)
.expect(200);
// Assert
expect(res.body.data).to.be.an('array');
expect(res.body.data).to.have.lengthOf(2);
expect(res.body.data[0]).to.have.property('name', 'User 1');
});
// More integration tests...
});
});
Analogy: Testing controllers is like quality control in a factory production line. Unit tests check that each machine (controller method) functions correctly in isolation, with simulated inputs and outputs. Integration tests verify that the entire production line works together, from raw materials (HTTP requests) to finished products (HTTP responses).
Advanced Controller Patterns
Controller Factory Pattern
For resources with similar CRUD operations, a controller factory can reduce code duplication:
// utils/controllerFactory.js
const AppError = require('./appError');
const createController = (Model) => {
return {
getAll: async (req, res, next) => {
try {
const documents = await Model.find();
res.status(200).json({ data: documents });
} catch (error) {
next(error);
}
},
getOne: async (req, res, next) => {
try {
const document = await Model.findById(req.params.id);
if (!document) {
return next(new AppError('Document not found', 404));
}
res.status(200).json({ data: document });
} catch (error) {
next(error);
}
},
create: async (req, res, next) => {
try {
const document = await Model.create(req.body);
res.status(201).json({ data: document });
} catch (error) {
next(error);
}
},
update: async (req, res, next) => {
try {
const document = await Model.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!document) {
return next(new AppError('Document not found', 404));
}
res.status(200).json({ data: document });
} catch (error) {
next(error);
}
},
delete: async (req, res, next) => {
try {
const document = await Model.findByIdAndDelete(req.params.id);
if (!document) {
return next(new AppError('Document not found', 404));
}
res.status(204).json(null);
} catch (error) {
next(error);
}
}
};
};
module.exports = createController;
Using the Factory
// controllers/user.controller.js
const User = require('../models/user.model');
const createController = require('../utils/controllerFactory');
// Create base controller methods
const userController = createController(User);
// Add custom methods
userController.search = async (req, res, next) => {
try {
const { query } = req.query;
const users = await User.find({
$or: [
{ name: { $regex: query, $options: 'i' } },
{ email: { $regex: query, $options: 'i' } }
]
});
res.status(200).json({ data: users });
} catch (error) {
next(error);
}
};
module.exports = userController;
Class-Based Controllers
For an object-oriented approach, controllers can be implemented as classes:
// controllers/BaseController.js
class BaseController {
constructor(Model) {
this.Model = Model;
}
getAll = async (req, res, next) => {
try {
const documents = await this.Model.find();
res.status(200).json({ data: documents });
} catch (error) {
next(error);
}
}
getOne = async (req, res, next) => {
try {
const document = await this.Model.findById(req.params.id);
if (!document) {
return res.status(404).json({ error: 'Document not found' });
}
res.status(200).json({ data: document });
} catch (error) {
next(error);
}
}
// Other CRUD methods...
}
module.exports = BaseController;
// controllers/UserController.js
const BaseController = require('./BaseController');
const User = require('../models/user.model');
class UserController extends BaseController {
constructor() {
super(User);
}
// Custom methods specific to users
search = async (req, res, next) => {
// Implementation...
}
}
module.exports = new UserController();
Real-world example: The NestJS framework (built on Express) uses class-based controllers as a core architectural pattern. Their controllers are decorated classes with methods that handle specific routes, allowing developers to leverage inheritance, dependency injection, and other OOP features while maintaining a clear separation between routes and business logic.
Authentication and Authorization in Controllers
Controllers often need to handle authentication and authorization concerns:
Authentication Middleware
Authentication is typically implemented as middleware that runs before controller methods:
// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/user.model');
const authenticate = async (req, res, next) => {
try {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Find user
const user = await User.findById(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// Attach user to request
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
module.exports = { authenticate };
Authorization Middleware
Authorization middleware checks user permissions:
// middleware/auth.js
// ... authentication middleware
const authorize = (...roles) => {
return (req, res, next) => {
// Check if user exists (should be set by authenticate middleware)
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
// Check if user has required role
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Permission denied' });
}
next();
};
};
module.exports = { authenticate, authorize };
Applying Auth to Routes
// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { authenticate, authorize } = require('../middleware/auth');
// Public routes
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
// Protected routes - require authentication
router.post('/', authenticate, userController.createUser);
// Admin-only routes - require authentication and admin role
router.put('/:id', authenticate, authorize('admin'), userController.updateUser);
router.delete('/:id', authenticate, authorize('admin'), userController.deleteUser);
module.exports = router;
Resource Ownership Authorization
For resource-specific authorization, use middleware or controller logic:
// middleware/auth.js
// ... other middleware
const checkOwnership = (Model) => {
return async (req, res, next) => {
try {
const resource = await Model.findById(req.params.id);
if (!resource) {
return res.status(404).json({ error: 'Resource not found' });
}
// Check if the authenticated user is the owner
if (resource.user.toString() !== req.user.id) {
return res.status(403).json({ error: 'Permission denied' });
}
// Resource exists and user is owner
req.resource = resource;
next();
} catch (error) {
next(error);
}
};
};
// routes/post.routes.js
router.put('/:id',
authenticate,
checkOwnership(Post),
postController.updatePost
);
Response Formatting
Consistent response formatting is important for API usability. Controllers should follow a standardized response structure.
Response Format Utility
// utils/response.js
const formatResponse = (data, message = null, meta = {}) => {
const response = {};
if (data !== undefined) {
response.data = data;
}
if (message) {
response.message = message;
}
if (Object.keys(meta).length > 0) {
response.meta = meta;
}
return response;
};
const formatError = (message, code = 'INTERNAL_ERROR', details = null) => {
const error = {
message,
code
};
if (details) {
error.details = details;
}
return { error };
};
module.exports = { formatResponse, formatError };
Using Response Formatters in Controllers
// controllers/user.controller.js
const { formatResponse, formatError } = require('../utils/response');
const userService = require('../services/user.service');
const userController = {
getAllUsers: async (req, res, next) => {
try {
const { page = 1, limit = 20 } = req.query;
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
userService.getAllUsers(skip, limit),
userService.countUsers()
]);
const totalPages = Math.ceil(total / limit);
res.status(200).json(formatResponse(users, null, {
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages
}
}));
} catch (error) {
next(error);
}
},
getUserById: async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
if (!user) {
return res.status(404).json(formatError(
'User not found',
'NOT_FOUND'
));
}
res.status(200).json(formatResponse(user));
} catch (error) {
next(error);
}
},
// Other methods...
};
module.exports = userController;
Response Middleware Approach
Alternatively, you can standardize responses using middleware that wraps res.json:
// middleware/response.js
const responseFormatter = (req, res, next) => {
// Store the original res.json method
const originalJson = res.json;
// Override res.json method
res.json = function(body) {
// Skip if already formatted
if (body && (body.data || body.error || body.message)) {
return originalJson.call(this, body);
}
// Format the response
const formatted = {
data: body,
meta: {
timestamp: new Date().toISOString(),
requestId: req.id || undefined
}
};
// Call the original json method with the formatted response
return originalJson.call(this, formatted);
};
// Continue to the next middleware
next();
};
// Apply to all routes
app.use(responseFormatter);
Real-world example: Financial API providers like Plaid use consistent response formatting in their controllers to ensure that all endpoints return data in a predictable structure. Their responses always include standardized status indicators, request identifiers, and well-structured data objects, which makes it easier for developers to integrate with their services.
Practice Activities
Activity 1: Basic Controller Implementation
Implement a complete controller for a resource of your choice:
- Create a User model with basic fields (name, email, password, role)
- Implement a UserController with CRUD operations
- Create routes that map to controller methods
- Add proper error handling to all controller methods
- Implement basic validation for inputs
Test your controller using Postman or curl to ensure it works correctly.
Activity 2: Service Layer Implementation
Enhance your controller implementation with a service layer:
- Create a UserService that encapsulates database operations
- Refactor your UserController to use the service
- Add complex business logic in the service (e.g., user activation, search)
- Implement unit tests for both the controller and service
- Add meaningful error messages and proper status codes
Compare the maintainability and testability of this approach with the basic controller implementation.
Activity 3: Controller Factory Pattern
Implement a controller factory for multiple resources:
- Create a controller factory that generates CRUD methods
- Implement at least three different resource controllers (e.g., users, products, orders)
- Add custom methods to each controller beyond the generated ones
- Implement consistent response formatting across all controllers
- Add authentication and authorization to protected routes
Create an API documentation file explaining the endpoints and their functionality.
Key Takeaways
- The Controller Pattern separates route definitions from implementation logic, improving maintainability
- Controllers should focus on HTTP-specific concerns like request parsing and response formatting
- Resource-based organization is the most common approach for controller structure
- Consistent naming conventions make controllers more understandable and predictable
- Proper error handling is essential for robust controller implementation
- The Service Layer Pattern further separates business logic from HTTP concerns
- Controllers can be tested in isolation by mocking dependencies
- Controller factories and class-based patterns reduce code duplication
- Authentication and authorization should be handled before controller execution
- Consistent response formatting improves API usability
With a solid understanding of the Controller Pattern, you can build Express applications that are well-organized, maintainable, and testable.