模块边界设计:身份一致性 vs 形态相似性

2026/05/01

核心命题

模块的合并,应当基于"身份的一致性",而非"形态的相似性"。

违反这个原则的代价:已量产、已稳定的模块,因为另一个模块的需求变更而被牵连,被迫进行不必要的回归测试和风险评估。


一、问题场景

两个东西——比如两套代码、两个业务模块、两个微服务——它们在结构上高度相似,甚至在很多地方完全一样。直觉会告诉你"应该合并成一个抽象层"。

但实际上它们不是同一个东西。它们有不同的变更原因、不同的维护者、不同的生命周期。把它们强制合并在一起,短期减少了代码行数,长期增加了耦合风险。

这个问题在以下场景反复出现:

领域例子
嵌入式 BSPF4 系列 USART 驱动 vs H7 系列 USART 驱动——寄存器相似但不是同一个 IP
业务系统两个产品的审批流程"看起来一样",但合规要求和变更节奏不同
微服务两个不同团队维护的用户模块,API 签名相似但内部逻辑独立演进
数据层两个表结构相似,但一个服务订单、一个服务退货单,未来字段会分化

二、理论来源

这个问题被软件工程界多次独立发现并讨论,形成了一套跨领域共识。

2.1 Parnas(1972):按变更原因划分模块

David Parnas 在《On the Criteria To Be Used in Decomposing Systems into Modules》中提出:

模块不应该按执行流程来划分(流程图怎么走,模块就怎么分),而应该按可能独立变更的设计决策来划分。

两个 USART 驱动的"流程图"一样——都是初始化、发数据、中断处理——按流程图分就会合并。但它们的设计决策不同——两个芯片的 IP 是两个独立决策,未来会各自演进、各自出勘误表、各自被替代。

Parnas 的判断标准:如果模块 A 因为决策 X 变更,模块 B 不受影响,那么 X 就应该被封装在 A 内部。 F4 的勘误表变更不应该影响 H7,所以 F4 的 USART 驱动和 H7 的 USART 驱动应该是两个独立模块。

2.2 单一职责原则(SRP):一个模块只有一个变更原因

Robert C. Martin 在 2003 年形式化了 SRP,其核心定义并不是"一个类只做一件事",而是:

一个模块应该只有一个变更原因。

Martin 进一步引入了"变更责任人"的概念——“一个模块应该只对一个角色(actor)负责”。F4 驱动对 F4 产品团队负责,H7 驱动对 H7 产品团队负责。如果它们在同一个模块里,那么任何一个团队的变更都会牵连另一个团队的代码,违背了 SRP。

判断方法:找出谁想让这个模块变更。如果有超过一个来源,它就有超过一个职责。

2.3 Conway 定律(1967):系统结构镜像组织边界

Melvin Conway:

设计系统的组织,其产生的设计等同于这些组织的沟通结构。

当 F4 和 H7 由不同团队维护时,共享的驱动代码会变成两个团队都必须沟通才能改动的东西。每次变更都需要跨团队协调、联合评审、共同回归测试。沟通成本被固化为代码依赖。

Conway 定律的正向推论同样重要:如果你希望系统模块化,组织边界就应该和代码边界对齐。 芯片系列独立 → 团队独立 → 代码独立。这是反推——按组织边界设计代码边界,而不是反过来。

2.4 Rule of Three(1999):三次才值得抽象

Martin Fowler 在《重构》中提出:

第一次,写一个特定的解决方法。 第二次,拷贝上一次的代码。 第三次,才着手抽象,写出通用的解决方法。

为什么不是第一次?因为你只有一个样本,不知道哪些是"本质的共性",哪些是"碰巧的相似"。为什么不是第二次?因为两个样本仍然不足以可靠判断——你不知道第三个变体会打破你刚抽出来的抽象。

F4 和 H7 只有两个样本。未来的 GD32E2 系列会有什么不同,没人知道。此时的抽象就是过早的。

2.5 Sandi Metz(2014):重复比错误抽象更便宜

Sandi Metz 在 2014 年 RailsConf 上明确提出了这个结论:

“Duplication is far cheaper than the wrong abstraction.”

她的论证:

2.6 Bounded Context(DDD,2003):同一个词在不同上下文里含义不同

Eric Evans 在《领域驱动设计》中提出了 Bounded Context 概念:

同一个术语,在不同的上下文中可能代表完全不同的概念。强行统一为一个模型,会导致含义被稀释,最终谁也说不清这个模型代表什么。

“USART 驱动"在 F4 上下文和 H7 上下文中看起来是同一个术语,但背后是两个不同的硬件 IP。强行合并为一个"通用 USART 驱动”,就是强行统一了两个 Bounded Context 中的不同概念。


三、判断框架

当面对"两个东西要不要合并"的决策时:

3.1 正确的标准:身份一致性

问:它们是同一个东西吗?
  ├── 是同一个 IP 的同一个实现?            → 合并
  ├── 是同一个业务规则的同一个定义?         → 合并
  ├── 是同一个数据实体的同一个 schema?      → 合并
  └── 不是上面的情况,只是看起来像?         → 分开

3.2 实用的判别法:勘误表测试

如果一个的勘误表(或 bug 列表、变更记录)不能覆盖另一个,它们就不是同一个东西。

3.3 实用的判别法:变更原因测试

列出未来可能导致这个模块变更的原因。如果两个东西的变更原因列表有本质差异,它们应该分开。


四、代价分析

4.1 合并但应该是独立的代价

代价说明
牵连风险A 的 bug 修复导致 B 回归
认知负担维护者必须理解所有使用场景才能改动任何一个
组织耦合不同团队被迫共同评审和测试对方的改动
抽象脆弱未来第三个变体出现时,现有抽象大概率被打破
已量产风险已发布产品的代码因为另一个产品的需求变更而被修改

4.2 分开但本可以合并的代价

代价说明
代码量稍多通用逻辑被复制了几十行
bug 可能需要在多处修复如果真的是同一个 bug 逻辑

对比结论:分开但可能多修一次 bug 的代价 < 合并造成的测试范围牵连和已量产风险的代价。


五、应用指南

5.1 什么时候复制优于抽象

  1. 样本不足三个:不要基于两个样本抽抽象,等第三个出现再说
  2. 变更原因不同:不同的人/角色驱动变更,不应共享
  3. 生命周期不同:一个已量产一个在开发,已量产的不应该因为新项目的抽象需求被改动
  4. 不确定:宁可先复制,等"真正的共性"自己浮现

5.2 什么时候抽象合理

  1. 同一个身份:同一个 IP、同一个业务规则、同一个实体
  2. 变更同步:A 的每一个变更,B 必然也需要——因为它们是同一个东西
  3. 纯工具:数据结构和算法,不绑定任何特定上下文

5.3 组织层面的操作


六、相关文献

来源核心贡献
Parnas, 1972 — On the Criteria To Be Used in Decomposing Systems into Modules按变更决策划分模块,不是按流程图
Conway, 1967 — How Do Committees Invent?系统结构镜像组织沟通结构
Fowler, 1999 — RefactoringRule of Three:第三次出现才抽象
Martin, 2003 — Agile Software DevelopmentSRP:一个模块只有一个变更原因
Evans, 2003 — Domain-Driven DesignBounded Context:同一术语在不同上下文中含义不同
Metz, 2014 — RailsConf Keynote重复比错误抽象更便宜
Hunt & Thomas, 1999 — The Pragmatic ProgrammerDRY 的正确含义:消除知识重复,而非代码相似