去偶合正交设计对架构演进的作用

从函数、目录到包边界,理解去耦合、正交设计与可演进架构

Share
去偶合正交设计对架构演进的作用
Image Generated By GPT-6 Astra

你接到一个简单的需求,把会员折扣从九折改成八五折。

按理说,修改计价规则就够了。可你打开项目,发现折扣写在下单函数里,下单函数又直接创建支付客户端、更新数据库、发送邮件。为了放心修改一个数字,你不得不重新研究整条业务链路。

于是团队开始整理代码。大家创建了 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 框架。具体目录名与层数不是那条原则本身。

FIG. 01 源代码依赖方向 / Source-code dependencies

这张图表示源代码依赖方向,不是运行时调用顺序。数据库实现可以在运行时被应用层调用,同时在源代码中实现应用层定义的接口。

这也是 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 的内部类型,支付包也不需要知道整个订单模型。

FIG. 02 包之间的集成边界 / Package integration boundaries

这张图表示包与模块的源代码依赖。它没有画 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 历史“经常一起改”就直接合并。如果两部分共同修改只是由于现有设计错误,合并可能是在固定那个错误。还需要检查业务原因。

公共包不能成为所有依赖的交汇点

本例如果只是复用金额取整,就不应顺手依赖一个同时初始化邮件客户端、数据库连接和消息订阅的万能工具包。

问题不一定是这些代码会全部执行,而是这个包让本来不需要的版本、初始化行为或接口变化也进入了维护范围。

发现循环依赖时,先找职责问题

假设订单包调用支付包,支付包又反向调用订单包来更新状态。

FIG. 03 双向依赖 / Circular dependency

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

FIG. 04 通过用例编排协作 / Workflow orchestration

但这不是唯一答案。也可能是两个模块本来就不该拆开,或者需要一个真正独立的业务概念。

不要为了让图上没有环,就把所有类型搬进 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()。因此通知服务变慢,下单流程也会等;通知永远不返回,异常捕获也不会自动结束等待。

FIG. 05 等待通知的下单流程 / Waiting for notification 代码层面可替换,不等于延迟、可用性和故障已经独立。

如果业务要求支付后立即返回,再异步通知,就需要另外设计消息交接与失败处理,而不只是把 await 删除。

用异步机制时,不要把可靠性问题藏起来

一种常见方案是 Transactional Outbox,事务发件箱:在同一个本地数据库事务中,记录业务状态和待发布消息,再由独立任务转发消息。它解决的是本地状态与消息记录的一致性问题。重复投递仍可能出现,所以消费侧需要设计 Idempotency,幂等性,即重试同一操作时避免重复产生不该重复的效果。

FIG. 06 本地事务与消息发布 / Local transaction and message delivery

这个本地事务不包括第三方支付公司的扣款操作。支付重试、结果核对与补偿,仍需要自己的设计。

同样,一条通知发出后程序崩溃,重新消费会不会再发一条,不能只靠“发送前查询一下”就宣称完全解决。必须检查实际副作用发生处支持什么去重与重试语义。

把消息队列引入系统,改变的是协作方式与故障模型,不会让业务依赖凭空消失。

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.