Node.js 后端的四层结构:一次真实的重构记录
一个路由文件从 200 行长到 1800 行之后,我做了一次分层重构。这篇记录了坏味道长什么样、四层的边界怎么划、事务放哪一层。
去年我接手维护的一个 Node.js 服务,核心是一个订单相关的模块。最开始只有一个 routes/orders.ts,两百多行,看着挺清爽。半年后它变成了 1800 行,任何改动都要先花二十分钟读代码确认「有没有别的地方也依赖这段逻辑」。
这不是某一个人的问题。路由文件膨胀是所有「先跑起来再说」的服务都会走到的终点,它只是需要一次重构,而不是需要一次追责。
一、重构前的样子
这是当时最典型的一个接口,我做了删减,但保留了它全部的坏味道:
// routes/orders.ts —— 重构前
router.post('/orders', async (req, res) => {
const { userId, items, couponCode } = req.body;
if (!userId || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'invalid params' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. 校验用户
const user = await client.query('SELECT * FROM users WHERE id = $1', [userId]);
if (user.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'user not found' });
}
// 2. 算价格(含优惠券、会员折扣、运费)
let total = 0;
for (const item of items) {
const p = await client.query('SELECT price, stock FROM products WHERE id = $1', [item.productId]);
total += p.rows[0].price * item.quantity;
await client.query('UPDATE products SET stock = stock - $1 WHERE id = $2', [item.quantity, item.productId]);
}
if (couponCode) {
const c = await client.query('SELECT * FROM coupons WHERE code = $1', [couponCode]);
if (c.rows.length && c.rows[0].expires_at > new Date()) total = total * 0.9;
}
if (user.rows[0].is_member) total = total * 0.95;
// 3. 落库
const order = await client.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING id',
[userId, total, 'pending'],
);
await client.query('COMMIT');
// 4. 通知(第三方 SDK,失败会抛异常)
await notifyService.send(user.rows[0].phone, `订单 ${order.rows[0].id} 已创建`);
await pool.query('INSERT INTO order_events (order_id, type) VALUES ($1, $2)', [order.rows[0].id, 'created']);
res.json({ id: order.rows[0].id, total });
} catch (err) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'internal error' });
} finally {
client.release();
}
});
问题可以列一长串,但真正致命的只有三个:
- 它同时承担了 HTTP、业务规则、SQL 三种职责,改运费的算法要动接口文件,风险不可控。
- 事务里包含了外部调用(
notifyService.send)——虽然写在了COMMIT之后,但那段INSERT INTO order_events的失败会让接口返回 500,而订单其实已经创建成功了。 - 它没法测试。要验证「会员再打 95 折」这条规则,必须起一个数据库、准备用户和商品数据、调 HTTP 接口。
二、四层怎么分,边界在哪
我按职责切成四层,划分依据不是「业界都这么分」,而是变更频率和依赖方向:越靠上的层越容易变,越靠下的层越稳定,依赖只能从上往下。
| 层 | 职责 | 允许依赖 | 不可以做 |
|---|---|---|---|
| 控制器 Controller | HTTP 出入参、状态码、鉴权 | 服务层 | 写业务规则、碰 SQL |
| 服务 Service | 业务规则、事务边界、编排 | 仓储接口、领域对象 | 感知 req / res |
| 仓储 Repository | 数据读写、行到对象的映射 | 数据库客户端 | 写业务判断 |
| 基础设施 Infra | 连接池、日志、配置、外部客户端 | —— | 依赖上面任何一层 |
有一条规则是我这次重构里最坚持的:服务层不认识 req 和 res。只要服务层还接收 req,业务逻辑就一定会慢慢长回 HTTP 细节。
三、重构后的样子
先定义层间契约。类型放在独立文件里,让「谁依赖谁」在编译期就能看出来:
// domain/types.ts
export interface OrderItemInput {
productId: string;
quantity: number;
}
export interface Order {
id: string;
userId: string;
total: number;
status: 'pending' | 'paid' | 'cancelled';
createdAt: Date;
}
// 仓储接口只描述「需要什么能力」,不描述「怎么实现」
export interface OrderRepository {
create(input: { userId: string; total: number }): Promise<Order>;
addItems(orderId: string, items: OrderItemInput[]): Promise<void>;
}
export interface ProductRepository {
findByIds(ids: string[]): Promise<Map<string, { price: number; stock: number }>>;
decreaseStock(id: string, quantity: number): Promise<void>;
}
数据访问层把这些接口落到 PostgreSQL 上。注意返回的一律是领域对象,snake_case 的行数据在这一层内部就被翻译掉了,不往上泄漏:
// infra/pg-order-repository.ts
import type { Order, OrderRepository } from '../domain/types.ts';
import type { Pool, PoolClient } from 'pg';
const toOrder = (row: any): Order => ({
id: row.id,
userId: row.user_id,
total: Number(row.total),
status: row.status,
createdAt: row.created_at,
});
// client 由外部传入:可能来自连接池,也可能来自一个已开启的事务
export const createOrderRepository = (client: Pool | PoolClient): OrderRepository => ({
async create(input) {
const { rows } = await client.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[input.userId, input.total, 'pending'],
);
return toOrder(rows[0]);
},
async addItems(orderId, items) {
await client.query(
`INSERT INTO order_items (order_id, product_id, quantity)
SELECT $1, * FROM UNNEST($2::uuid[], $3::int[])`,
[orderId, items.map((i) => i.productId), items.map((i) => i.quantity)],
);
},
});
服务层承载业务规则,并且事务边界就在这一层。理由很实际:一次下单要跨两个仓储(订单、商品),事务必须比单个仓储更大;而如果放到控制器,控制器就得同时知道数据库的存在,第 4 层和第 1 层的边界会立刻糊掉。
// services/order-service.ts
import type { OrderRepository, ProductRepository } from '../domain/types.ts';
export interface UnitOfWork {
run<T>(fn: (tx: { orders: OrderRepository; products: ProductRepository }) => Promise<T>): Promise<T>;
}
export const createOrderService = (uow: UnitOfWork, notify: Notifier, logger: Logger) => ({
async placeOrder(input: { userId: string; items: OrderItemInput[]; couponCode?: string }) {
const order = await uow.run(async (tx) => {
const prices = await tx.products.findByIds(input.items.map((i) => i.productId));
let total = 0;
for (const item of input.items) {
const p = prices.get(item.productId);
if (!p) throw new AppError('商品不存在', { status: 404 });
if (p.stock < item.quantity) throw new AppError('库存不足', { status: 409 });
total += p.price * item.quantity;
await tx.products.decreaseStock(item.productId, item.quantity);
}
total = applyDiscounts(total, input.couponCode);
const created = await tx.orders.create({ userId: input.userId, total });
await tx.orders.addItems(created.id, input.items);
return created;
});
// 事务提交之后再发通知:外部调用不进事务
notify.sendOrderCreated(order).catch((err) => logger.warn({ err, orderId: order.id }, 'notify failed'));
return order;
},
});
依赖注入没有引入任何容器,就是最朴素的构造参数 + 工厂函数。这是我在这次重构里另一个明确取舍:装饰器和容器在学习成本、调试体验上对小团队不划算,显式传参虽然啰嗦,但顺着一个文件的 createXxx() 就能看完全部依赖。
// main.ts —— 组装根,唯一知道具体实现的地方
const pool = new Pool({ connectionString: config.databaseUrl });
const uow: UnitOfWork = {
async run(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn({
orders: createOrderRepository(client),
products: createProductRepository(client),
});
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
},
};
const orderService = createOrderService(uow, notifyClient, logger);
app.post('/orders', createOrderController(orderService));
控制器被压到十几行,只剩 HTTP 该关心的事:
// controllers/order-controller.ts
export const createOrderController = (service: ReturnType<typeof createOrderService>) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
const order = await service.placeOrder(req.body);
res.status(201).json({ id: order.id, total: order.total });
} catch (err) {
next(err); // 统一交给错误中间件映射状态码
}
};
四、迁移顺序:不要一次重写
我最初的想法是「找个周末全量重写」,很快被自己否决了——这个模块承载着线上交易,两天不发布是不可接受的。实际采用的是绞杀者模式:新旧两条路径并存,按接口逐个迁移。
- 先建好四层的骨架和接口定义,此时它们还没有被任何路由使用,随时可以推翻。
- 挑风险最低的只读接口(订单详情查询)先迁移,验证分层确实跑得通。
- 迁移写接口,每迁完一个,就从旧文件里删掉对应的 handler。
- 全部迁完之后删除旧文件,再补上针对服务层的单元测试。
整个过程发了六次版,每次只动一个接口。中途任何一个版本出问题,回滚成本都是一次部署,而不是一次重构。 这里有个实用的小技巧:旧 handler 在删除前先改成抛出一个带堆栈的错误,如果线上还有流量打进来,日志里立刻就能看到漏迁的调用点。
五、重构之后,测试确实变简单了
这是分层最直接的收益。以前测「库存不足要拒绝下单」,需要真实数据库;现在只需要一个假的仓储:
const fakeUow: UnitOfWork = {
run: (fn) => fn({
orders: { create: async (i) => ({ id: 'o1', status: 'pending', createdAt: new Date(), ...i }), addItems: async () => {} },
products: {
findByIds: async () => new Map([['p1', { price: 100, stock: 1 }]]),
decreaseStock: async () => {},
},
}),
};
const service = createOrderService(fakeUow, { sendOrderCreated: async () => {} }, silentLogger);
await expect(service.placeOrder({ userId: 'u1', items: [{ productId: 'p1', quantity: 5 }] }))
.rejects.toThrow('库存不足');
这个测试跑完不到 10ms,而且不依赖环境。重构前那 1800 行里,能这样测的逻辑几乎为零——不是没人想写,而是没有任何一个可以被单独调用的单元。
覆盖率上也能看出来:重构前这个模块整体覆盖率是 12%(只有零星几个工具函数被测到),重构后服务层的业务规则覆盖率超过 80%。测试数量并没有增加多少,变化在于终于有东西可以被测了。
六、代价,以及什么时候不要这么分
分层是有成本的,我不想把它说成没有代价的正确答案:
- 文件数从 3 个变成 17 个,读一个接口的完整链路需要在四个文件之间跳转。
- 简单的 CRUD 会被写得更长。一个「按 ID 查订单」,四层走下来的代码量是直接查库的三倍。
- 过度抽象的风险真实存在。我现在的做法是:只有当一个接口里出现了「业务规则」或者「跨两个以上数据源」,才给它建 service;纯查询接口允许控制器直连仓储。
判断标准我总结成一句话:如果一段逻辑我需要在两个以上的入口复用它,或者需要为它单独写测试,它就值得有自己的层。 否则让它留在原地,等它自己长到需要搬家的时候。
重构完成后,routes/orders.ts 从 1800 行变成了 42 行。更重要的是,后面三次需求变更(改运费规则、加优惠券类型、接入新的通知渠道)都只动了单个文件。
本文由 Kyne 撰写,采用 CC BY-NC-SA 4.0 许可,转载请注明出处。