业务逻辑从路由里拆出来:Service 层的职责边界与复用
Service 层的核心价值是「去 HTTP 化、可复用、可测试」。本文讲解为何要把业务逻辑从路由回调里拆出来,明确路由/Controller、Service、Model/DAO 三层的职责边界,并给出创建订单这种典型场景的封装示例,演示一个不依赖 req/res 的 Service 如何被路由、定时任务等多处复用。

问题背景
一开始图省事,我习惯把业务逻辑直接写进路由的回调里:拿参数、查库、算价格、改库存、写日志,一口气全堆在 router.post('/order') 的函数体里。结果只要这段逻辑被别的地方复用(比如定时任务、后台接口、消息队列消费者),就只能复制粘贴,改一处漏三处。拆出 Service 层,就是为了让业务逻辑 只写一遍、处处可用 。
原理说明
分层的关键是每个"层"只干自己的活,别越界:
- 路由 / Controller 层 :只负责 接请求、取参数、校验入参、把结果塞进响应。不写查库、不算业务。
- Service 层 :负责真正的业务逻辑。它可以编排多个数据模型,做事务、算价格、检查状态,抛出业务错误。它是可复用的核心,不依赖 HTTP 对象(
req/res),这样既能被路由调用,也能被定时任务、脚本调用。 - Model / DAO 层 :只做数据读写,不含业务判断。
判定标准很简单: 如果一段代码同时又"校验入参"又"算业务"又"管响应格式",那它就横跨了三层 ,就该拆。Service 的方法签名应该是普通函数参数(比如传对象、传 id),而不是 req ,这样它就对 HTTP 无感知。
示例代码
一个"创建订单"的 Service:
// services/orderService.js
const db = require('../models/db');
// 创建订单:扣库存 + 写订单在一个事务里完成
async function createOrder({ userId, items }) {
// 业务校验:入参不合规直接抛错,由上层统一处理
if (!items || items.length === 0) {
const err = new Error('下单商品不能为空');
err.status = 400;
throw err;
}
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const total = await calcTotal(items); // 计算总价
await deductStock(connection, items); // 扣库存
const [result] = await connection.query(
'INSERT INTO orders (user_id, total) VALUES (?, ?)',
[userId, total]
);
await connection.commit();
return { id: result.insertId, total };
} catch (e) {
await connection.rollback();
throw e;
} finally {
connection.release();
}
}
async function calcTotal(items) {
// 业务计算逻辑,可单独复用和测试
return items.reduce((sum, it) => sum + it.price * it.qty, 0);
}
module.exports = { createOrder, calcTotal };路由层引用它,变得非常干净:
// controllers/orderController.js
const orderService = require('../services/orderService');
async function create(req, res, next) {
try {
const order = await orderService.createOrder({ userId: req.user.id, items: req.body.items });
res.status(201).json({ code: 0, data: order });
} catch (err) {
next(err); // 交给统一异常兜底
}
}新的使用方(比如一条定时任务)也能直接复用:
// jobs/retryOrder.js
const orderService = require('../services/orderService');
await orderService.createOrder({ userId, items });常见坑与解决方案
- 坑一:Service 方法里写了
req.params或res.status,导致它离不开 HTTP。解法:参数用普通值传入,返回普通数据,res交给调用方。 - 坑二:一个 Service 方法做了三件事,又查又算又发通知,改动牵连过大。解法:拆成粒度更小的纯函数,再在高层编排。
- 坑三:事务忘记提交或回滚,数据错乱。解法:严格
beginTransaction+ 成功commit+ 异常rollback,finally里释放连接。 - 坑四:业务错误直接用普通 Error 抛出,前端拿不到合适的提示。解法:给错误对象挂上
status和业务码,供异常兜底中间件读取(下一篇会细讲统一异常)。
小结
Service 层的价值在于"去 HTTP 化、可复用、可测"。边界守住了,路由永远是薄薄一层,业务可以任意调用。下一节我们把异常处理规范化,让 Service 抛的错误能变成前端看得懂的东西。
FAQ(常见问题)
Q:Service 方法里能不能直接用 req 和 res ?
A:不能。Service 一旦依赖 HTTP 对象就失去了复用价值,只能被路由调用。正确做法是用普通函数参数(对象、id 等)传入,返回普通数据, res 交给调用方去写响应。
Q:一个 Service 方法到底该做几件事?
A:尽量小而专注。如果一个方法既校验入参又算业务又发通知还管响应格式,那就横跨了多层,应该拆成粒度更小的纯函数再在高层编排,否则改动一个点就牵连一大片。
Q:事务处理最容易在哪里出错?
A:最容易漏掉提交或回滚。要用严格的 beginTransaction + 成功 commit + 异常 rollback 结构,并在 finally 里释放连接,保证连接不泄漏、数据不错乱。






