去偶合正交设计对架构演进的作用
从函数、目录到包边界,理解去耦合、正交设计与可演进架构
你接到一个简单的需求,把会员折扣从九折改成八五折。
按理说,修改计价规则就够了。可你打开项目,发现折扣写在下单函数里,下单函数又直接创建支付客户端、更新数据库、发送邮件。为了放心修改一个数字,你不得不重新研究整条业务链路。
于是团队开始整理代码。大家创建了 controllers、services、repositories,又抽出了很多接口。目录变整齐了,修改一个需求却依然需要理解半个系统。
问题究竟出在哪里?
文件被分开了,不代表职责被分开了;接口被抽出来了,也不代表变化被隔离了。
这篇文章用一个小商店的下单系统,从函数走到目录,再走到包边界。我们关心的不是哪种结构看起来最“高级”,而是不同需求能否在清晰的边界内独立变化。
文中的 TypeScript 代码用于说明设计。支付、邮件和短信均为本地模拟;金额使用人民币分,只处理正金额和能够直接确认结果的支付。代码片段不是完整支付系统,不应直接用于真实交易。
Coupling, Cohesion & Orthogonality 先说清楚几个核心概念
- Coupling,耦合,可以理解为一个部分对另一个部分的依赖和了解程度。支付模块必须知道需要收多少钱,这很合理;它还必须知道邮件模板存在哪张表里,就值得追问原因。
- Cohesion,内聚,关心一个模块内部的内容是否围绕明确的职责组织。会员折扣与计价取整可以属于同一套计价规则,短信验证码发送通常不属于这套规则。
- High Cohesion,高内聚 与 Low Coupling,低耦合,分别追求职责集中和跨模块牵连可控。Decoupling,去耦合或解耦,就是减少不必要的牵连。它与内聚是相辅相成的:把应该一起变化的内容集中起来,把不该一起变化的内容分开。接下来介绍的单一职责原则,也会沿着“为什么会修改”来划分责任。
- Orthogonality,正交性,更强调不同维度能否独立变化。概念上无关的部分,不应在实现中被迫互相牵连。
拿下单系统来说,价格规则、支付渠道、通知方式是三个不同维度。我们希望改价格规则时,不必改支付实现;增加短信通知时,不必重新实现会员折扣。
但有一点必须说清楚:正交不意味着业务结果互不影响,而是避免不必要的代码修改。
九折改成八五折后,支付金额当然应该变化。支付模块能够继续接收新的金额,而不需要同步修改算法,这才是我们追求的独立性。
本文会反复使用两个检查视角。Change Locality,变更局部性,看改动能否集中在负责该需求的地方;Change Propagation,变更传播,看一次变化为什么会牵动其他模块。
它们不是要求每个需求只能改一个文件。修改业务规则,同时修改对应测试和说明,是正常的。真正值得警惕的是,调整折扣却必须重新理解邮件协议。
Hide Decisions, Not Just Fields 隐藏容易变化的设计决策
在讨论目录之前,先理解 Information Hiding,信息隐藏。
这里的“隐藏”不是简单地把属性标成 private,而是让模块外部不需要知道某些内部设计决策。与其只按照处理步骤切分代码,不如想想哪些决定经常变化,以及如何让它们的变化停留在边界里面。
把这个思想放进我们的案例,可以问三个问题。
计价算法改变时,哪些调用方根本没必要知道?支付厂商更换时,哪些业务代码不应该理解新接口?通知模板存储方式调整时,为什么订单流程需要关心?
由此产生的边界,比“这个文件超过 300 行,拆一下”更有理由。
例如,计价模块对外只需要提供计算结果。会员资格如何影响折扣、怎样取整,可以留在里面。支付模块对外表达业务支付能力,厂商的字段名称和错误码映射留在实现中。
模块不只是装代码的盒子,也是限制外部需要知道多少事情的边界。
这就是 Encapsulation,封装 在本例中的实际价值。
Start with a Checkout Function 从一个下单函数开始
先看一个最直接的版本。这里的 createDemoPayments 和 createDemoNotifications 都来自本地模拟模块。
async function checkoutBefore(order: Order) {
const amountCents = Math.round(
order.subtotalCents * (order.member ? 0.9 : 1),
);
const payments = createDemoPayments("card");
const notifications = createDemoNotifications("email");
const result = await payments.charge({
reference: order.id,
totalCents: amountCents,
});
await notifications.deliver({
recipientId: order.userId,
template: "payment-confirmed",
values: { paymentId: result.transactionId },
});
}
这个函数不因为短小直接就一定不好。可一旦折扣、支付和通知开始分别变化,它就承担了三种不同的修改压力。
让同一类变化有自己的位置
我们把计价交给 Pricing,支付交给 PaymentPort,通知交给 ReceiptNotifier,让 Checkout 负责安排流程。
这对应 SRP,Single Responsibility Principle,单一职责原则。判断职责时,先看修改原因:如果两段逻辑总是为同一类业务要求而变,它们适合放在一起;如果分别响应不同要求,就应考虑分开。
因此,SRP 不是“一类只能有一个方法”,也不是“一个流程不能调用三个模块”。协调下单步骤,本来就可以是一项清楚的职责。
下面是精简后的业务约定。为了便于阅读先写在一起,后面再讨论它们应该放进哪些文件和包。
type Order = Readonly<{
id: string;
userId: string;
subtotalCents: number;
member: boolean;
}>;
type Receipt = Readonly<{
orderId: string;
amountCents: number;
paymentId: string;
}>;
interface Pricing {
calculate(order: Order): number;
}
interface PaymentPort {
// Resolving means payment is confirmed, not merely initiated.
pay(orderId: string, amountCents: number): Promise<Receipt>;
}
interface ReceiptNotifier {
send(userId: string, receipt: Receipt): Promise<void>;
}
interface 在这里是一份能力说明书。它让不同对象可以提供同一种能力,这种替换使用的方式就是 Polymorphism,多态。不过,类型结构相容,只能检查约定的一部分,不能自动证明行为也相容。
流程依赖能力,而不是供应商
有了约定,下单流程可以这样写。
class Checkout {
constructor(
private readonly pricing: Pricing,
private readonly payment: PaymentPort,
private readonly notifier: ReceiptNotifier,
) {}
async placeOrder(order: Order): Promise<{
receipt: Receipt;
notification: "sent" | "failed";
}> {
requirePositiveCents(order.subtotalCents);
const amountCents = this.pricing.calculate(order);
requirePositiveCents(amountCents);
const receipt = await this.payment.pay(order.id, amountCents);
try {
await this.notifier.send(order.userId, receipt);
return { receipt, notification: "sent" };
} catch {
return { receipt, notification: "failed" };
}
}
}
function requirePositiveCents(value: number): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError("金额必须是正的安全整数,单位为分");
}
}
这里同时明确了一条结果边界。支付失败,流程不会继续发送成功通知;支付已经成功但通知失败,返回的结果不能把已成功的支付抹掉。
异常捕获只是在这个示例中区分结果,并没有实现可靠补发、故障记录或超时处理。
Checkout 依赖业务所需要的能力,而不依赖某家厂商的客户端。这是 DIP,Dependency Inversion Principle,依赖倒置原则 的体现。它约束的是源代码依赖方向,不是把程序执行顺序反过来。
把协作者从构造函数传进来,叫 DI,Dependency Injection,依赖注入。DI 解决“依赖怎么交进来”,DIP 解决“业务代码依赖什么”,两者不能画等号。即使把厂商客户端从外面传入,业务也可能继续绑定它的专有类型。
这里还使用了 Composition,对象组合:把计价、支付和通知能力组装成流程,而不是为每种组合创建一个巨大子类。这是本文示例的选择,不是说任何继承都应该被禁止。
Interfaces Are Behavioral Contracts 接口不仅是类型,还包括行为
这一节补上 SOLID 中另外三个与本例直接相关的原则。
OCP,让已识别的变化可以通过扩展实现
OCP,Open/Closed Principle,开闭原则,强调让稳定部分尽量通过扩展获得新行为,而不是随着每个新实现反复修改。它不是禁止修改已有代码,也不承诺提前预测一切需求。
在本例中,增加满足既有支付约定的电子钱包实现,可以不修改 Checkout。但供应商配置、适配器和测试仍然需要增加,这些是新功能本身的工作,不是架构失败。
同样,原本只支持人民币,现在要求多币种,金额模型本来就可能需要变化。不能为了守住“旧代码一行不改”,让新需求使用一个不准确的模型。
LSP,替换实现不能偷偷改变含义
LSP,Liskov Substitution Principle,里氏替换原则,要求替换实现时,不破坏调用方依赖的行为约定。它关注的不只是继承语法,还包括返回值、状态和错误的实际含义。
我们的 PaymentPort.pay() 正常返回意味着支付成功。另一个实现如果只创建了“等待扫码”的请求,就不能把它伪装成成功回执。
真实支付可能需要等待用户操作,也可能仍在处理中。“请求已创建”和“钱已付成功”不是同一个状态。
这时应该修改支付结果模型,例如显式区分 pending 和 succeeded,并重新安排通知时机。良好的抽象应当准确表达能力,而不是掩盖能力差异。
ISP,只依赖自己真正需要的能力
ISP,Interface Segregation Principle,接口隔离原则,强调调用方不应被迫依赖自己不需要的能力。
下单流程只需要完成支付,没有必要依赖一个同时包含退款、对账、风控规则配置、银行账户管理的 EverythingPaymentService。
所以我们给它 PaymentPort。将来退款流程可以有自己的 RefundPort。这并不是要求每个方法都创建一个接口,而是根据调用方的真实需求划分能力。
到这里,SOLID 不再是五句口号。它们分别帮助我们检查职责、扩展位置、替换行为、接口大小和依赖方向。
Organize by Layer or by Feature? 目录按技术分,还是按业务分
代码中的职责逐渐清楚以后,下一个问题才是它们应该放在哪里。
Package by Layer,按技术层划分
一种常见的组织方式是先按技术职责分类。
src/
├── controllers/
│ ├── order-controller.ts
│ └── payment-controller.ts
├── services/
│ ├── order-service.ts
│ └── payment-service.ts
├── repositories/
│ ├── order-repository.ts
│ └── payment-repository.ts
└── models/
├── order.ts
└── payment.ts
Directory structure / 目录结构
这里首先回答“这是 Controller、Service 还是 Repository”。这种分层可以作为简单系统的起点,不能仅因为它按技术分类就判定为坏设计。
但在这个例子中,一个订单需求的相关代码横跨多个大目录。services 的名称能告诉你文件的技术角色,却不能告诉你订单边界在哪里。
Package by Feature,按业务功能划分
另一种方式是先按业务能力聚合。
src/
├── orders/
│ ├── order.ts
│ ├── place-order.ts
│ ├── order-repository.ts
│ └── order-http.ts
├── payments/
│ ├── charge.ts
│ ├── payment-status.ts
│ └── payment-http.ts
└── notifications/
├── send-receipt.ts
└── email-sender.ts
Directory structure / 目录结构
这里首先回答“这是订单、支付还是通知”。相关业务代码集中在同一处,更容易找到;但组织位置与真正的封装仍然不是一回事。
Package by Component,按组件组织,进一步强调把一组业务能力及其内部实现收进清晰的接口后面。调用方使用订单组件,而不必直接知道它的数据库类。
对于本文这个订单不断演进的案例,我倾向于先让业务归属在目录上可见。寻找订单流程时,先进入 orders,不必从所有业务共用的 services 开始筛选。
不过,把文件搬进同一个目录不会自动消除耦合。orders 仍然可以越过公开接口,直接修改 payments 的内部状态。
目录解决“东西放在哪”;边界还要解决“别人允许知道什么、允许修改什么”。
Vertical Slice,不只是给文件夹换个名字
Vertical Slice Architecture,垂直切片架构,更强调围绕具体请求或用例,把完成该能力需要的代码组织在一起。每个切片可以根据自己的复杂度选择内部结构,不必全部使用同一套模板。
例如,“下单”和“导出订单报表”可以是不同切片。前者需要严格业务规则,后者可能主要是查询和输出。没有必要强迫两者都走同样长的 Controller → Service → Repository 调用链。
“按业务能力分组”“垂直切片”和“按组件组织”有重叠,但不是三个可以无条件互换的名称。本文采用的是一个具体的混合选择:外部先按业务归属组织,内部按复杂度决定是否分层。
Layers Inside a Business Module 业务模块内部再按需分层
一个复杂的订单模块,可以进一步长成下面这样。这是结构示意,不是要求每个简单模块都创建相同目录。
src/modules/orders/
├── index.ts # Public API
├── domain/
│ ├── order.ts # Business model and invariants
│ └── pricing.ts # Pricing policy
├── application/
│ ├── place-order.ts # Checkout use case
│ └── ports/
│ ├── payment-port.ts # Required payment capability
│ └── order-repository.ts # Required persistence capability
├── infrastructure/
│ └── postgres-order-repository.ts # Database adapter
└── transport/
└── order-http.ts # HTTP mapping
Directory structure / 目录结构
Domain,领域层,表达业务概念和规则。Business Invariant,业务不变量,指业务状态始终需要满足的约束,例如在我们简化的模型中,只有确认支付成功后才能生成成功回执。
Application,应用层,安排一个 Use Case,用例 的步骤。例如加载订单、调用计价、发起支付、保存结果。Orchestration,编排,就是这种协调多个能力的工作,不意味着把各模块内部规则也搬过来。
Infrastructure,基础设施层,处理数据库、外部服务、文件系统等技术实现。Transport,传输或接入层,处理 HTTP、命令行、消息消费等入口。
这种安排与 Clean Architecture 的核心方向一致:外部实现依赖内部业务约定,而不是让业务规则依赖数据库和 Web 框架。具体目录名与层数不是那条原则本身。

这张图表示源代码依赖方向,不是运行时调用顺序。数据库实现可以在运行时被应用层调用,同时在源代码中实现应用层定义的接口。
这也是 Ports and Adapters,端口与适配器架构 的关键想法。Port,端口,表达业务需要或提供的交互能力;Adapter,适配器,把具体技术转换成这种能力,让业务内部不必了解外部技术的全部细节。
以订单存储为例,应用层声明它需要“按 ID 查找”和“保存订单”,数据库适配器实现这些操作。应用层没有必要接收数据库驱动的完整查询对象。
这里可以使用 Repository,仓储抽象。但只有它真正隔离了业务需要与存储细节,才有意义。把 ORM 的所有方法原封不动包装一遍,再起名 Repository,并不一定创造了新的边界。
也不要机械地建立四层。一段简单的价格函数可以只有一个文件;当业务规则、外部调用与存储实现确实开始独立变化,再引入对应边界。
Directories, Modules, Packages & Services 四种边界,不是同一回事
讨论“拆模块”时,最好先说明我们正在拆哪一层。
| 层级 | 本文使用的含义 | 主要解决的问题 |
|---|---|---|
| Directory,目录 | 文件的组织位置 | 在哪里查找代码 |
| Module,业务模块 | 一组有清晰职责和接口的业务代码 | 谁负责什么 |
| Package,包 | 在本例中由 package.json 描述的依赖与导出单元 | 可以依赖谁、公开什么 |
| Service,服务 | 运行和部署层面的独立单元 | 如何独立运行、部署和扩缩容 |
这里的 Module 是架构意义上的业务模块,不是专指 TypeScript 的一个模块文件。Node.js 对 package 有更具体的定义,围绕 package.json 所界定的目录树组织。
拆包不等于拆服务。 多个业务包仍然可以被同一个应用加载、一起构建和部署。这样有模块边界、但不强求独立服务部署的设计,可以采用 Modular Monolith,模块化单体 的思路。
Monorepo,单仓多项目,在本文例子中只表示多个应用与包放在同一个仓库中管理;npm workspaces 提供了这种本地多包协作方式。它并不替我们决定部署架构。
为了展示可执行的包边界,我们把示例组织成下面这样。
shop/
├── package.json # Workspace configuration
├── tsconfig.json # Project references
├── apps/
│ └── demo/
│ └── src/
│ ├── main.ts # Select implementations and start
│ └── integrations.ts # Map between module contracts
├── packages/
│ ├── checkout/
│ │ ├── package.json
│ │ └── src/
│ │ ├── index.ts
│ │ ├── domain/
│ │ │ ├── order.ts
│ │ │ └── pricing.ts
│ │ └── application/
│ │ ├── checkout.ts
│ │ └── ports.ts
│ ├── payments/
│ │ ├── package.json
│ │ └── src/
│ │ ├── index.ts
│ │ ├── api.ts
│ │ └── internal/demo-payments.ts
│ └── notifications/
│ ├── package.json
│ └── src/
│ ├── index.ts
│ ├── api.ts
│ └── internal/demo-notifications.ts
├── tests/
└── scripts/
└── check-architecture.mjs
Directory structure / 目录结构
这里故意没有给支付和通知模拟器创建四层空目录。它们足够简单,api.ts 与 internal 就能表达当前需要的边界。
而 checkout 内部确实有计价规则与用例编排两个关注点,所以分别放在 domain 和 application。
这个拆包规模是教学选择,不是说十几个文件的实际项目必须马上建四个包。小项目完全可以先用目录实现相同职责划分。
Make the Public Boundary Explicit 明确公开什么,以及不公开什么
支付包的内部实现可以随时调整。它对外只需要公开特定类型和一个创建函数。
// packages/payments/src/index.ts
export { createDemoPayments } from "./internal/demo-payments.js";
export type {
PaymentsApi,
ChargeRequest,
ChargeResult,
} from "./api.js";
这是 Public API,公开接口。这里的 API 不一定是 HTTP 接口,也可以是其他代码允许使用的类型、函数或对象。
index.ts 可以作为统一导出入口,常被称为 Barrel File,聚合导出文件。但在本例中,我们选择逐项导出,而不是用 export * 把所有内部文件全部公开。
否则,internal 只会成为一个没有约束力的名称。
用包导出限制入口
支付包的 package.json 可以这样配置。
{
"name": "@shop/payments",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
对应的调用方式是:
// Use the public package entry point.
import { createDemoPayments } from "@shop/payments";
// Avoid depending on an internal package file.
import { createDemoPayments } from
"@shop/payments/dist/internal/demo-payments.js";
第二种叫 Deep Import,深层导入。上面的 exports 没有公开这个子路径,Node 在按包名解析时会拒绝它。这样,内部文件改名就不应该成为外部调用方的合法依赖。
但必须区分接口限制与安全隔离。exports 不是强安全封装,绝对路径等方式仍可能访问文件。因此,跨包相对路径、路径别名等绕行方式,还需要其他依赖规则约束。
private: true 也不是源码访问控制开关;npm 将它用于防止包被发布。它不能代替公开 API 与导出边界设计。
路径变短,不等于依赖变合理
有些项目会通过 TypeScript paths,让任意源码目录都能被方便导入。
import type { InternalPaymentRow } from
"@internal/payments/database/payment-row";
路径看起来很整洁,却仍然可能是对支付内部持久化类型的依赖。
TypeScript 的 paths 不会改写输出代码里的导入路径;把同仓包直接映射到源码,也可能绕过正常包解析中的 exports 等行为。这个例子采用 workspace 包解析,而不是把所有包都映射到 src。
这里还要注意 Type Coupling,类型耦合。import type 会从运行时输出中移除,但调用方仍然依赖被导入类型的结构和语义。数据库字段改变,依赖该类型的业务仍然可能被迫修改。
没有运行时导入,不代表没有架构依赖。
Who Owns the Interface? 接口放在哪,往往更重要
现在出现一个更细的问题。
PaymentPort 应该放在 checkout、payments,还是一个公共 contracts 包里?
在本文采用的设计中,PaymentPort 表达“下单流程需要的支付能力”,所以由 checkout 拥有。这叫 Consumer-owned Port,消费方拥有的端口。它把业务需要与具体提供方分开,与依赖倒置中让抽象服务于调用方的方向一致。
与此同时,支付包可以有自己的公开约定。
// packages/payments/src/api.ts
export interface PaymentsApi {
charge(request: {
reference: string;
totalCents: number;
}): Promise<{
transactionId: string;
}>;
}
下单叫 orderId,支付叫 reference。下单需要业务回执,支付只返回交易标识。这两份约定不必强行合并成一个“全系统通用支付对象”。
在应用集成层加入一个小型适配器。
// apps/demo/src/integrations.ts
import type { PaymentPort } from "@shop/checkout";
import type { PaymentsApi } from "@shop/payments";
export function createPaymentAdapter(api: PaymentsApi): PaymentPort {
return {
async pay(orderId, amountCents) {
const result = await api.charge({
reference: orderId,
totalCents: amountCents,
});
return {
orderId,
amountCents,
paymentId: result.transactionId,
};
},
};
}
适配器同时理解两侧公开约定,负责翻译。checkout 不需要导入 payments 的内部类型,支付包也不需要知道整个订单模型。

这张图表示包与模块的源代码依赖。它没有画 checkout → payments,不代表运行时不会发生支付,而是运行时调用通过注入的端口实现完成。
我们把具体对象的组装放在 Composition Root,组合根,也就是靠近程序入口、集中选择实现并组装对象的位置。
// apps/demo/src/main.ts (imports omitted)
const checkout = new Checkout(
new MemberPricing(),
createPaymentAdapter(createDemoPayments("card")),
createNotificationAdapter(createDemoNotifications("email")),
);
组合根可以分成多个有组织的启动文件,不需要变成一个几千行的 main.ts。重点是业务逻辑不应到处自行查找全局容器或选择供应商。
同时,也不要把这个例子机械推广成“每两个模块之间都必须有适配器”。如果一个公开接口已经稳定、语义也与调用方完全一致,直接依赖它可能更简单。适配器应该隔离真实差异,而不是制造没有作用的转发层。
Design Principles at Package Scale 把设计原则推到包层级
类的职责清楚,不代表包的边界自然合理。一个包含计价、邮件和数据库工具的 common 包,仍然可能把无关变化捆绑在一起。
到了组件与包这一层,可以用下面四个原则继续检查边界。名称看起来抽象,但问题很具体。
| 原则 | 用普通话理解 |
|---|---|
| CCP,Common Closure Principle,共同闭包原则 | 因为同一类原因一起变化的代码,倾向于放在同一组件内 |
| CRP,Common Reuse Principle,共同复用原则 | 不应让使用者为了需要的部分,依赖一大组不需要的内容 |
| ADP,Acyclic Dependencies Principle,无环依赖原则 | 组件之间不应形成循环依赖 |
| SDP,Stable Dependencies Principle,稳定依赖原则 | 依赖方向应考虑变化的阻力,避免稳定部分被不稳定细节牵动 |
这里的“稳定”不只是最近很久没提交代码,也包括改变它有多困难、多少调用方会受影响。
一起改的内容,不一定要分到不同大目录
假设订单状态、合法状态转换和订单保存约定,总是在同一类需求中一起调整。它们可能更适合由一个订单模块拥有,而不是分别扔进全局 models、validators、repositories,再依靠命名约定维持联系。
反过来,不能只根据 Git 历史“经常一起改”就直接合并。如果两部分共同修改只是由于现有设计错误,合并可能是在固定那个错误。还需要检查业务原因。
公共包不能成为所有依赖的交汇点
本例如果只是复用金额取整,就不应顺手依赖一个同时初始化邮件客户端、数据库连接和消息订阅的万能工具包。
问题不一定是这些代码会全部执行,而是这个包让本来不需要的版本、初始化行为或接口变化也进入了维护范围。
发现循环依赖时,先找职责问题
假设订单包调用支付包,支付包又反向调用订单包来更新状态。

一种可能的改法,是把跨模块工作交给应用层流程。

但这不是唯一答案。也可能是两个模块本来就不该拆开,或者需要一个真正独立的业务概念。
不要为了让图上没有环,就把所有类型搬进 shared。图可能变漂亮了,所有模块依然共享一套不稳定的大模型。
无环是一个有用的约束,不是低耦合的充分证明。
Business Boundaries Go Deeper 相同名称,不一定是相同业务
进一步考虑,订单、支付、通知能不能共享同一个 Status 枚举?
当然能写出来,但未必应该这样设计。
订单中的“完成”,可能意味着交付结束;支付中的“完成”,意味着款项确认;通知中的“完成”,可能只是消息已提交给发送服务。这些词看起来相同,业务含义却不同。
这涉及 DDD,Domain-Driven Design,领域驱动设计 中的 Bounded Context,限界上下文。它划定一套模型和语言适用的范围。不同上下文之间通过明确映射协作,而不是强迫所有人使用同一个万能模型。
但不是每个文件夹、数据库表或者类都对应一个 Bounded Context。计价函数可能只是订单上下文内部的一部分,是否独立成领域,要看业务规则和协作方式。
对于本文案例,可以分别表达订单状态与支付状态,再通过明确操作关联。
type OrderStatus = "awaiting_payment" | "paid" | "cancelled";
type PaymentStatus = "pending" | "succeeded" | "failed";
这两份模型没有必要共享一个包含十几种状态的大枚举。支付成功如何触发订单变化,由明确的工作流决定。
共享类型本身没有错,但共享一个名字,等于共享它背后的含义与变化责任。
同样,DRY,Don't Repeat Yourself,不重复表达同一知识,也不等于“看到相似字段就必须抽公共类”。需要避免的是同一条业务知识散落在多个位置,而不只是代码外观相似。
两个都包含 id 和 status 的对象,可能代表不同业务事实;一条价格规则被复制到三个地方,却可能是真正需要消除的重复。
Coupling Beyond Imports 数据、时间与失败也会产生耦合
到这里,包依赖图可能已经很清楚。但图中没有箭头,不代表运行时没有关系。
Data Coupling 数据层面的耦合
这里用“数据耦合”泛指通过共享数据结构、表或状态形成的联系,不特指教科书中某一种参数耦合等级。
假设订单、支付和通知都直接读写同一张支付表。支付包内部修改字段名称,其他包可能同时失效,即使源码从未导入支付包。
除了字段结构,锁竞争等运行时行为也可能让这些模块互相影响。模块化单体同样需要检查这些关系。
这不意味着一个模块必须对应一套物理数据库。一个更适合本文例子的起点,是明确 Data Ownership,数据所有权:谁拥有表结构、迁移和写入规则,其他模块通过哪些允许的途径访问。
报表或分析确实可能需要跨表读取。可以明确建立读模型、数据契约或受控查询边界,而不是把所有直接访问都装作不存在。
Temporal Coupling 时间上的耦合
本例 Checkout 虽然只依赖通知接口,仍然会 await notifier.send()。因此通知服务变慢,下单流程也会等;通知永远不返回,异常捕获也不会自动结束等待。

如果业务要求支付后立即返回,再异步通知,就需要另外设计消息交接与失败处理,而不只是把 await 删除。
用异步机制时,不要把可靠性问题藏起来
一种常见方案是 Transactional Outbox,事务发件箱:在同一个本地数据库事务中,记录业务状态和待发布消息,再由独立任务转发消息。它解决的是本地状态与消息记录的一致性问题。重复投递仍可能出现,所以消费侧需要设计 Idempotency,幂等性,即重试同一操作时避免重复产生不该重复的效果。

这个本地事务不包括第三方支付公司的扣款操作。支付重试、结果核对与补偿,仍需要自己的设计。
同样,一条通知发出后程序崩溃,重新消费会不会再发一条,不能只靠“发送前查询一下”就宣称完全解决。必须检查实际副作用发生处支持什么去重与重试语义。
把消息队列引入系统,改变的是协作方式与故障模型,不会让业务依赖凭空消失。
Turn Boundaries into Checks 把设计约定变成自动检查
架构文档写“不要跨模块访问内部实现”,很有必要,但还不够。
我更希望在这个示例中看到三个可以执行的约束:业务包只能发布指定入口,领域层不能反向引用应用或基础设施实现,跨包访问不能使用相对路径钻进别人的源码。
这类检查可以称为 Architecture Tests,架构测试。它们验证的不是折扣算得对不对,而是代码是否遵守团队声明的依赖规则。
例如,在已经配置 TypeScript 解析的 ESLint 项目中,可以增加一个局部导入限制。以下只是规则片段,不是完整 ESLint 配置。
{
rules: {
"no-restricted-imports": ["error", {
patterns: [{
group: ["@shop/*/src/**", "@shop/*/dist/**"],
message: "跨包调用请使用公开入口,不要依赖内部文件。",
}],
}],
},
}
ESLint 的 no-restricted-imports 支持按路径模式限制导入。但这段规则只针对写出的路径形式,不能因此宣称已经覆盖所有相对路径、别名或动态加载方式。
需要查看更完整的依赖图时,可以使用 dependency-cruiser 这类静态分析工具。其规则支持循环依赖等约束,规则范围仍应根据项目实际配置。
也可以编写针对项目自身规则的 TypeScript AST 检查脚本。AST,Abstract Syntax Tree,抽象语法树,可以简单理解为把源码解析成结构,便于检查导入等语法。
这样的脚本应检查类型导入、普通导入、重新导出和需要支持的动态导入;识别跨包相对路径、未公开包子路径、反向层依赖,以及文件图中的循环。还应当用故意违规的样本验证规则,确保检查真的能发现问题。
自定义检查的能力取决于扫描范围和解析规则。只检查 src/**/*.ts,就无法覆盖范围外的文件;没有处理的路径别名也可能绕过检查。即使所有导入都合法,也不能据此证明没有数据或语义耦合。
将这些检查放进 CI,Continuous Integration,持续集成 的构建流程,让每次集成的代码自动接受构建与检查,避免边界仅在文章示例中成立。
行为测试与架构测试要分开看
Unit Test,单元测试,可以验证会员计价,不需要支付服务。Integration Test,集成测试,检查模块接在一起时,字段、结果与调用顺序是否正确。Contract Test,契约测试,则针对共同能力约定,检查不同实现是否满足同一组可观察行为。这些测试侧重点不同,不应互相替代。
例如,本例的支付实现应该都满足“成功返回时有有效交易标识”“失败不伪装成成功”。但本地模拟通过这些检查,不代表真实厂商接入已经验证。
对于这个案例,测试应覆盖不同计价、支付、通知的组合,以及支付失败、通知失败、金额校验、适配器字段映射和非法导入。严格类型检查、架构检查和行为测试各自负责一部分,不能用其中一项代替全部。
Judge the Design by Real Changes 最后仍要回到真实需求
抽出更多接口、消除所有循环之后,仍然需要回到需求上看收益。
下面是本文设计在约定成立时的预期变化范围。
| 需求变化 | 主要修改位置 | 不应该无故牵动的内容 |
|---|---|---|
| 会员九折改八五折 | 计价规则或配置、对应测试 | 支付厂商接入、通知模板 |
| 新增兼容现有契约的支付渠道 | 支付实现、组装与契约测试 | 会员算法、下单核心流程 |
| 邮件改成短信 | 通知实现和组装 | 计价与支付规则 |
| 调整数据库字段或 ORM | 所属模块的数据映射与迁移 | 未暴露这些细节的调用方 |
| 支付从立即成功变为异步确认 | 支付契约、状态处理、相关流程 | 不能承诺只换一个适配器 |
| 支持多币种 | 金额模型和相关业务契约 | 这是合理的跨边界变化 |
| 新增“指定银行卡立减” | 计价输入与优惠规则、相关集成 | 不应把整个计价算法复制到支付实现 |
最后一行尤其能说明正交设计的边界。
当优惠确实取决于支付方式,计价与支付之间就存在业务关联。正确做法是明确传递优惠资格或必要事实,而不是假装它们完全无关。
减少人为耦合,不等于消除真实业务关系。
对于已有项目,我会先选一个近期会变化的功能,沿着真实调用路径找出责任归属,补上行为测试,再收紧公开接口与依赖规则。目录移动放在这之后,而不是作为重构完成的证据。
当模块确实需要独立构建、多个应用复用、清晰的依赖声明或更严格的入口约束时,再考虑升级为独立包。独立部署、扩缩容或故障边界有真实需求时,再讨论服务化。
不必为了一个小功能立即拆成微服务,也不必为了保留单体而允许所有部分互相读写内部状态。
Keep Change Under Control 让变化发生在应该发生的地方
从函数走到目录,再走到包层级,我们反复处理的其实是同一个问题。
哪些事情属于同一项职责?哪些信息不需要让外部知道?哪些依赖是业务必需,哪些只是实现方便?某个部分变化时,其他部分为什么必须跟着变化?
SRP 帮我们划职责,DIP 帮我们定依赖方向,OCP 帮我们为实际变化留下扩展位置,LSP 与 ISP 帮我们检查能力约定。到了包层级,还要继续考虑共同变化、共同复用和依赖环。
但这些原则不会替我们做完判断,也不存在适用于所有项目的唯一目录树。
好的目录让人找得到代码,好的包边界让人知道什么可以依赖,而好的正交设计,让人只理解相关部分,就有把握完成一次修改。
设计的目标不是把项目拆得尽可能碎,也不是把架构图画得尽可能复杂。
当你只是想把会员折扣从九折改成八五折时,你终于可以修改计价规则,运行相关测试,而不必重新研究支付网关和邮件系统。
这才是设计带来的实际收益。
Good design keeps change where it belongs.