Separation of controllers and services
Separation of Controllers and Services
In the Express framework, controllers are responsible for handling HTTP requests and responses, while services focus on business logic. Separating the two improves code maintainability and testability, preventing controllers from becoming bloated. This architectural pattern allows developers to organize code more clearly, especially in large projects.
Why Separation is Needed
When all logic is written in controllers, the code becomes difficult to maintain. For example, a user registration controller might include steps like validation, database operations, and sending emails. As business logic grows more complex, controllers become increasingly large and testing becomes harder. After separation, controllers only handle receiving requests, calling services, and returning responses, while services manage the actual business logic.
// Not recommended: All logic is in the controller
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'User already exists' });
}
// Create user
const user = new User({ email, password });
await user.save();
// Send welcome email
await sendWelcomeEmail(email);
return res.status(201).json({ message: 'User created' });
});
How to Implement Separation
Move business logic to the service layer, and let controllers simply call services. Services are typically independent classes or modules that can be reused by multiple controllers. This separation makes business logic easier to test, as services can be tested independently without mocking HTTP requests.
// services/userService.js
class UserService {
async register(email, password) {
if (!email || !password) {
throw new Error('Email and password are required');
}
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new Error('User already exists');
}
const user = new User({ email, password });
await user.save();
await sendWelcomeEmail(email);
return user;
}
}
// controllers/userController.js
const userService = new UserService();
app.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
const user = await userService.register(email, password);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Design Principles for the Service Layer
Services should be stateless and not depend on request or response objects. They accept plain parameters and return plain values or Promises. This allows services to be used in any context, not just HTTP requests. Services can also call each other to form more complex business logic.
// services/orderService.js
class OrderService {
constructor(userService) {
this.userService = userService;
}
async createOrder(userId, products) {
const user = await this.userService.getById(userId);
if (!user) {
throw new Error('User not found');
}
// Order creation logic...
}
}
Best Practices for Error Handling
Controllers are responsible for converting service-layer errors into appropriate HTTP responses. The service layer throws semantic errors, and controllers catch these errors and decide how to present them to the client. Custom error classes can be used to distinguish between different types of errors.
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 400) {
super(message);
this.statusCode = statusCode;
}
}
// services/userService.js
throw new AppError('User not found', 404);
// controllers/userController.js
app.get('/users/:id', async (req, res) => {
try {
const user = await userService.getById(req.params.id);
res.json(user);
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message });
}
});
Convenience for Testing
After separation, the service layer can be tested independently without starting the Express application. This makes tests run faster and focus more on business logic. Controller tests can then focus on routing and HTTP-level behavior.
// tests/userService.test.js
describe('UserService', () => {
it('should register a new user', async () => {
const userService = new UserService();
const user = await userService.register('test@example.com', 'password');
expect(user.email).toBe('test@example.com');
});
});
// tests/userController.test.js
describe('UserController', () => {
it('should return 400 for invalid input', async () => {
const res = await request(app)
.post('/register')
.send({});
expect(res.statusCode).toBe(400);
});
});
Advantages of Dependency Injection
Injecting dependencies through constructors, rather than directly importing other modules within services, improves testability and flexibility. This makes it easy to replace real services with mock objects during testing.
// Using dependency injection
class OrderService {
constructor(userService, emailService) {
this.userService = userService;
this.emailService = emailService;
}
}
// Mock objects can be injected during testing
const mockUserService = { getById: jest.fn() };
const orderService = new OrderService(mockUserService);
Rational Use of Middleware
Although controllers and services are separated, Express middleware still has its value. Middleware is suitable for cross-cutting concerns like authentication and logging. Business logic should remain in the service layer.
// Middleware handles authentication
app.use((req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
// Controllers and services don't need to worry about authentication logic
app.get('/profile', async (req, res) => {
const user = await userService.getProfile(req.userId);
res.json(user);
});
Suggested Project Structure
A typical separated structure might look like this:
src/
controllers/
userController.js
orderController.js
services/
userService.js
orderService.js
models/
User.js
Order.js
routes/
userRoutes.js
orderRoutes.js
Route files are responsible for mapping paths to controller methods, keeping route definitions concise:
// routes/userRoutes.js
const express = require('express');
const userController = require('../controllers/userController');
const router = express.Router();
router.post('/register', userController.register);
module.exports = router;
Performance Considerations
While additional abstraction layers introduce slight performance overhead, this cost is negligible in most applications. The benefits of separation in terms of maintainability and scalability far outweigh the minor performance impact. For performance-critical paths, some logic can still be placed directly in controllers.
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn
上一篇:路由分层与模块化设计
下一篇:<br>-换行符