阿里云主机折上折
  • 微信号
Current Site:Index > Separation of controllers and services

Separation of controllers and services

Author:Chuan Chen 阅读数:58747人阅读 分类: Node.js

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

Front End Chuan

Front End Chuan, Chen Chuan's Code Teahouse 🍵, specializing in exorcising all kinds of stubborn bugs 💻. Daily serving baldness-warning-level development insights 🛠️, with a bonus of one-liners that'll make you laugh for ten years 🐟. Occasionally drops pixel-perfect romance brewed in a coffee cup ☕.