中间件把 HTTP 请求管道里的公共规则理顺了,却不会自动整理管道里面的业务代码。一个路由即使拥有漂亮的鉴权、日志和错误处理,也可能同时负责定价、支付、持久化和通知。到了这里,问题已经不再是代码能不能运行,而是一次需求变化会穿透多少职责。
这个矛盾在订单接口连续增加三个需求时彻底暴露了出来。
我第一次意识到“代码能跑”和“结构能改”是两回事,是在给一个订单接口加三项看似独立的小需求时:门店自取免运费,部分地区切换支付供应商,订单创建后再通知运营系统。
当时的 POST /orders 已经稳定运行了很久。我原以为改几个分支就够了:在路由里多判断一个 shippingMode,根据地区选择 SDK,再补一个通知请求。改完后,接口确实能返回 201,但测试环境很快出现了一组互相矛盾的现象:支付平台显示成功,订单却还停在 pending;门店自取偶尔仍收取运费;通知接口超时时,客户端已经收到了“创建成功”。
我的第一个猜测是网络抖动,于是给支付调用加重试。问题不但没消失,反而出现了重复扣款的风险。第二个猜测是数据库写入延迟,我又把几段保存逻辑塞进事务。结果事务时间变长,外部请求仍然无法被数据库回滚。
真正的转折发生在我把一次请求的调用链画出来之后:同一个路由函数同时读取 HTTP 参数、计算运费、换算支付金额、解释供应商状态、保存 ORM 文档、发邮件和写审计日志。任何需求变化都会穿过所有这些职责;所谓“偶发故障”,只是隐藏耦合终于被新需求放大了。
我没有立刻新建一堆 Manager 和抽象基类,而是先给变化分类:算法会换,外部接口会换,运行环境会换,副作用会增加,数据体积会增长。设计模式真正有用的地方,不是给代码贴名字,而是为每一种变化划出一个尽可能小的落点。
我把出问题的代码按“下一次还会因为什么而改”重新标记,得到了一张很实用的检查表:

这张表帮我避开了另一个坑:把模式当成项目初始化清单。两个稳定的 if 分支不必立刻变成策略注册表;只有一个简单的数据实现,也未必值得先造一套通用仓储。抽象会增加文件、命名和跳转成本,它必须换来一个明确收益:下一次变化发生时,大部分旧代码不用跟着改。
我现在判断一个抽象是否有效,只问一句:下次增加同类需求时,我是在新增一个局部实现,还是仍要同时修改路由、业务服务和数据访问代码?如果答案是后者,边界只是换了位置,并没有真正建立起来。
门店自取的 bug 最容易复现,我便从它开始。原来的运费判断散落在 Controller 和订单服务中,有的判断订单金额,有的判断配送方式。新增 pickup 后,我只改了一处,另一处仍然把 12 元运费加进了应付金额。
这个问题适合策略模式,因为变化的是“同一份订单如何计算运费”。我先把所有算法收敛成同一个签名,再让调用方只负责选择:
const shippingStrategies = {
standard(order) {
return order.total >= 9900 ? 0 : 1200;
},
express(order) {
return Math.max(2000, Math.ceil(order.weightKg) * 600);
},
pickup() {
return 0;
},
};
function
重构后的关键不在于少写了几个 if,而在于三个约束变清楚了:金额统一使用“分”,每个策略接收相同形状的订单,未知模式必须立即失败。增加一种配送方式时,只需要增加并测试一个函数。
策略选择最好发生在应用边界:Controller 先把用户输入校验成允许的枚举,服务再从策略表中取实现。策略函数不要偷偷读取 req、环境变量或全局用户状态,否则表面上可替换,实际仍被隐藏上下文绑住。
我也没有把所有条件判断都改成策略。如果分支只有两个、逻辑只有一两行,而且看不到独立演化的迹象,直接写 if/else 更清楚。策略开始划算,通常是因为算法需要独立测试、拥有不同依赖,或者要在运行时按配置切换。
支付状态不一致的原因,比我预想得更直接。供应商 A 接受 cents,返回 state: 'paid';供应商 B 接受以元为单位的数字,返回 approved: true。我最初在订单服务中做换算和状态判断,相当于让业务代码同时学习两套外部协议。每增加一个供应商,核心流程就多一层分支。
我先从应用视角定义所需能力:支付端口只有 charge({ orderId, amountCents })。供应商的字段、单位和状态都由适配器翻译:
function createProviderAAdapter(client) {
return {
async charge({ orderId, amountCents }) {
const result = await client.pay({
cents: amountCents,
reference: orderId,
});
return { paymentId: result.id, status: result.state };
},
};
}
function createProviderBAdapter(client) {
return {
async
适配器不是简单改个方法名。它还应该统一单位、状态、超时语义和可预期错误,让业务层只认识自己的模型。例如,外部的 APPROVED、SUCCESS 可以统一成 paid;外部错误则可以转换成应用能识别的 PaymentDeclinedError 或 PaymentUnavailableError。
这里也暴露出“策略”和“适配器”的区别:地区决定这一轮选择哪个支付实现,这是策略;把某个 SDK 翻译成统一的 charge(),这是适配器。多个适配器可以成为一组可切换策略,但两个模式解决的不是同一个问题。

我后来给适配器加了一条验收规则:业务服务中不能出现供应商专属字段。如果返回值里仍然装着 providerAResponse,或者换到 B 就要求服务额外传一个专用参数,那只是把耦合搬进了另一个文件。
支付还有一个必须单独处理的边界:超时不等于失败。请求超时时,供应商可能已经扣款,只是响应没有回来。因此重试前需要幂等键,并在状态不明时主动查询,而不是盲目再次调用 charge()。设计模式能整理接口,却替代不了支付一致性方案。
运费和支付的边界清楚后,测试依然很痛苦。要验证“空商品列表不能创建订单”,我仍得构造 req、res,启动 Express,还要让 ORM 连上数据库。原因是业务规则被夹在路由和模型调用之间。
我把请求链拆成三个问题:
下面这个最小服务把依赖方向写得很直白:Controller 依赖服务,服务依赖仓储所提供的能力,仓储不知道 HTTP 的存在。
const { randomUUID } = require("node:crypto");
const express = require("express");
function createOrderRepository() {
const records = new Map();
return {
async save(order) {
records.set(order.id, structuredClone(order));
return structuredClone(order);

拆开后,内存仓储可以换成数据库仓储,服务仍然只调用 save();定时任务也可以直接调用服务,无须伪造 HTTP 请求。更重要的是,我终于可以把业务失败与 HTTP 表达分开:服务抛出有意义的应用错误,Controller 或统一错误中间件再决定返回 400、409 还是 503。
仓储也不是把 ORM 的所有方法重新命名一遍。有效的接口应该使用业务语言,例如 findPendingOrders()、save(order),而不是机械复制 findAll、create、update、delete。后者很容易继续泄漏 ORM 的查询条件、分页格式和事务对象。
事务边界通常由服务层决定,因为只有用例知道哪些数据库写入必须一起成功;如何开始、提交和回滚,则可以交给仓储或工作单元。但数据库事务无法回滚已经发生的外部扣款。涉及支付时,仍要使用幂等、状态机、补偿或可靠消息等专门机制,不能把网络调用包进事务就当作原子操作。
同时,我会警惕“为了分层而分层”。如果 Controller、Service、Manager、Repository、DAO 每层都只是原样转发一次参数,结构只会增加跳转成本。边界应该对应真实的职责变化,而不是对应目录模板。
层次分开后,还有一些依赖藏在服务内部:数据库客户端从模块顶层导入,订单 ID 在服务中临时生成,环境变量在业务函数里读取。这样的函数看起来参数很少,实际依赖了一整个运行环境。
依赖注入并不要求引入容器框架。服务需要什么,就从参数传入;工厂负责创建依赖并把它们组装起来。集中完成组装的地方通常叫组合根,它应该靠近进程入口,而不是散落在业务模块中。
const { randomUUID } = require("node:crypto");
function createMemoryOrderRepository(initialOrders = []) {
const orders = new Map(initialOrders.map((order) => [order.id, order]));
return {
async save(order) {
orders.set(order.id, structuredClone(order));
return structuredClone

在这个结构里,固定时钟、固定 ID、内存仓储和故意报错的仓储都能直接注入。业务代码不需要判断当前是测试还是生产,也不需要修改全局时间。
我踩过的一个反例是“全局服务定位器”:每个模块都能从共享容器中按名称取数据库、缓存和配置。它确实减少了参数,却把真实依赖藏了起来;只看函数签名,没人知道运行它需要什么,测试还会互相污染共享容器。
另一个反例是给每个对象都建工厂。工厂适合处理有选择、有配置或有生命周期的创建过程。没有依赖的纯函数直接导出就够了,套一层 createX() 不会自动获得可替换性。
订单主流程稳定后,我才处理“通知运营系统”。最初的实现是服务直接调用邮件、审计和运营接口,任何一个超时都会拖慢请求;后来有人把这些调用改成没有 await 的 Promise,响应变快了,错误却变成无人处理的拒绝。
这里要先区分两类工作:决定下单是否成功的关键步骤,必须在服务中显式编排;日志、指标、缓存失效等非关键反应,可以订阅“订单已经创建”这个事实。Node.js 内置的 EventEmitter 足以实现进程内观察者:
const { randomUUID } = require("node:crypto");
const { EventEmitter } = require("node:events");
const orderEvents = new EventEmitter({ captureRejections: true });
orderEvents.on("error", (error) => {
console.error("事件监听器执行失败:", error.message);
});
emit() 不是异步队列。它会按注册顺序同步调用当前事件的所有监听器:同步监听器中的 CPU 密集任务会阻塞调用方;异步监听器运行到第一个 await 后才交回控制权,而 emit() 不会等待 Promise 完成,也不会收集返回值。
示例启用了 captureRejections: true,异步监听器拒绝的 Promise 会被转给 'error' 事件。同时必须显式监听 'error';否则 emitter 发出 'error' 时会抛出异常,进程可能退出。

事件名应该描述已经发生的事实,例如 order.created、payment.failed,而不是 send.email 这种命令。载荷则使用小而稳定的普通对象,不要直接塞入 Express 的 req、ORM 文档或 SDK 响应。还要注意,同一个载荷对象会依次传给所有监听器;团队可以约定只读,必要时在发布前复制或冻结。
监听器有生命周期。测试或热重载若反复调用 on() 却不调用 off(),副作用会重复执行,还可能触发监听器过多的警告。只响应一次的事件使用 once();长期订阅要保留函数引用,并在生命周期结束时解除。
CommonJS 模块缓存也可能制造一个隐蔽单例:文件若直接导出 new EventEmitter(),解析到同一文件的 require() 通常都会拿到同一个实例。需要隔离测试或应用实例时,导出 createOrderEvents() 工厂,再由组合根决定何时创建。
最重要的边界是可靠性。进程内事件没有持久化、跨进程投递、消费确认和自动重试。扣款、发放权益、必须送达的运营通知,不能只交给 EventEmitter。进程可能在订单提交后、监听器完成前退出。此时需要消息队列,或者把业务数据与待发布事件写入同一数据库事务的 outbox 方案。
这次重构临近结束时,订单导出又暴露了另一类问题。测试数据只有几千条,readFile() 或一次性拼接 CSV 都很顺;到了较大的数据集,进程内存快速上涨,并发导出时甚至被系统终止。
这不是业务职责混乱,而是数据体积这个变化没有边界。流把数据拆成块:Readable 产生数据,Transform 转换数据,Writable 消费结果。Node.js 的 pipeline() 能把多段流连成一条管道,统一传播错误,并在失败时清理相关流。
下面的脚本以文件压缩演示同一套机制:
const { createReadStream, createWriteStream } = require("node:fs");
const { pipeline } = require("node:stream/promises");
const { createGzip } = require("node:zlib");
async function compressFile(source, destination, signal) {
await pipeline(
await pipeline(...) 只有在整条管道完成后才兑现;任意一段报错,调用方都能在同一个 catch 中处理。相比手动为每一段分别监听 'error'、'finish' 和 'close',这更不容易漏掉清理和错误路径。

流能稳定处理大数据,关键不是“切块”两个字,而是背压。假设上游每秒读取 100 MB,下游网络每秒只能发送 10 MB;上游若不停生产,缓冲会持续挤占内存。使用 pipe() 或 pipeline() 时,Node 会协调上下游节奏。若手动调用可写流的 write(),就必须检查返回值:false 表示缓冲达到阈值,应暂停生产并等待 'drain'。
流也不是默认答案。如果业务必须看到全部数据才能排序、校验整体签名或执行跨记录规则,最后仍可能需要把内容收集到内存。此时应该设置明确的体积上限。把所有 chunk 推进无限增长的数组,并不会因为输入来自流就变得安全。
完成这些拆分后,我重新画了一遍创建订单的流程。它不再由某个“万能路由”掌控,而是由几个边界明确的角色协作:
Controller 校验 HTTP 输入,把它整理成不含 req、res
的普通对象,再调用订单服务。
订单服务显式编排关键步骤:选择运费策略,通过统一支付端口调用当前适配器,并根据用例规则保存状态。
仓储隐藏数据库细节;组合根创建仓储、支付适配器、策略集合、事件总线和服务,再把依赖注入 Controller。
数据库状态确定后发布
order.created。可丢失的进程内副作用交给监听器,必须送达的动作进入可靠消息方案。
组合之后,“成功”也必须被说清楚:数据库保存成功,不代表异步邮件已经发送;emit() 返回,不代表异步监听器已经完成;HTTP 返回 201,更不应该暗示所有后台副作用都已结束。接口响应、事件状态和日志需要表达各自完成到哪一步。
如果下单必须依次校验库存、请求支付、保存状态,就应该由服务层显式编排。把关键步骤拆成互相触发的监听器,会让执行顺序、失败位置和补偿动作都难以追踪。事件适合传播事实,不适合掩盖用例主流程。
策略函数若读取 req,支付端口若返回供应商原始响应,仓储若要求服务拼 ORM 查询条件,这些边界都只是名义上的。检查一个接口时,我会看测试替身需要模拟多少外部细节;模拟得越多,说明泄漏越严重。
监听器会抛错,流会中断,适配器会超时,仓储会遇到唯一约束冲突。每个边界都要回答四个问题:谁捕获错误,是否重试,重试是否幂等,需要记录哪些上下文。模式整理了协作关系,却不会自动生成可靠性。
不要在事件监听器、流回调或第三方适配器中只打印错误,然后继续对上层返回成功。错误被吞掉后,上层就失去了回滚、补偿、重试或返回正确状态码的机会,系统会表现成“请求成功,事情只做了一半”。
我会定期检查每一层是否拥有独立规则、是否隔离了一类真实变化。没有职责的中间层应当合并;只为遥远的可能性建立的接口,可以等第二个实现或真实测试替身出现后再提取。晚一点抽象,通常比长期维护一个猜错的抽象便宜。
现在再遇到“改一个需求却到处冒烟”的代码,我会按这个顺序处理:
那次订单改造给我留下的最大变化,不是项目里多了几个模式,而是我开始用“变化会落在哪里”审查代码。结构好的标志也不是文件足够多,而是新增一种运费算法、接入一个支付供应商、替换一个仓储或增加一个通知消费者时,改动能被限制在可预测的局部,失败也能沿着清晰的路径被看见和处理。
“失败能沿清晰路径被看见”听起来像设计完成后的自然结果,实际上还需要一套单独的契约:底层错误怎样补充上下文,HTTP 边界怎样分类,哪些操作可以重试,进程处于未知状态时怎样排空退出。模块边界解决变化传播,错误边界解决失败传播;缺少后者,前者只是把故障拆进了不同文件。
导出大量订单时,读取、转换和写入组成 pipeline,让背压约束数据流速,而不是把全部结果堆进内存。