# 模块化与迁移：划出能活下来的边界

> 不管你规划与否，模块边界最终都会长成组织结构的样子。真正有用的判据不是分层也不是文件类型，而是“隐藏那些可能会变的决策”，而它同时也是让迁移不必重写的前提。

- Canonical (HTML): https://kaguc.com/blog/modular-evolution-zh/
- Date: 2026-08-28


*English version: [Modularity and migration: cutting boundaries that survive](/blog/modular-evolution/)。本文是[交易类 App 架构](/blog/trading-app-architecture-zh/)系列第五篇。*

一个长期存活的应用有一个设计文档从不提及的性质：它必须一边改变形状，一边持续交付。没有人可以停下来一年。这个约束，比任何技术约束都更能决定哪种架构能活下来。

两个很老的想法在这里承担了大部分工作，第三个则告诉你该怎么移动。

## 你的架构会长成组织的样子

Conway 的观察是：系统的模块边界，最终会映射生产它的组织的沟通边界。这不是一句警告，而是一个足够可靠、可以据以设计的预测。

务实的读法是：如果八个团队做同一个应用，你大概会得到八个模块。唯一的问题是，这八个是不是你选的。与人们实际协作方式吻合的边界会很便宜，一个团队不必和别人协调就能交付；横切多个团队的边界则会永久昂贵，因为每一次改动都要开一次会。

所以模块化不只是代码组织练习，它是一个关于**谁可以不问谁就交付**的决定。这也是为什么它最终体现在构建时间、评审时长，以及有多少次发布被一个不相干的团队卡住。

## 在“可能会变的决策”处下刀

判据来自 Parnas，1972 年，至今仍是关于模块化最有用的一句话：一个模块应当**隐藏一个可能会变的决策**。

请注意它没有说什么。它没有说按技术分层来分组，模型放一块、视图放一块。那种排布是大多数团队的第一反应，而它恰好把目标搞反了：一次典型的改动是一个功能，而一个功能会碰到模型、视图和服务，于是每次改动都要碰每个模块。你造出的边界只增加了仪式，什么也没有隔离。

检验方法很简单：**拿出你最近十次有意义的改动，看每一次碰了几个模块。** 如果大多数只碰一个，你的边界与现实吻合；如果大多数碰了四个，说明你的模块是按文件类型划的，而不是按改动划的。修法是改为沿功能和易变决策来切：行情协议、图表引擎、认证流程、持久化选型。

## 核心的依赖应该是被交给它的

有了模块之后，问题是箭头朝哪边。依赖倒置给出答案：核心声明它需要的接口，比如一个网络传输、一个存储、一个时钟、一个日志器，由外部世界提供实现。核心不依赖任何具体实现。

由此得到三件事，这也是这个模式在大型系统里反复出现的原因：

- **核心可以被隔离测试**，因为每一个协作者都能换成假的，包括时钟。这正是你如何在不真的等待的情况下测试一次超时。
- **基础设施可以被替换**而不触碰业务规则。当且仅当核心里没有任何地方点了 HTTP 客户端的名，它才真的是一个实现细节。
- **那份接口清单就是真实的依赖文档。** 一个由十几个被注入能力初始化的核心，会明确告诉你它需要外界提供什么，这往往比 wiki 里那张架构图更诚实。

对应的坏味道是一个会自己往外伸手的核心：自己构造 HTTP 客户端、自己读系统时钟、自己调用单例日志器。它能跑，同时悄悄把业务规则焊死在基础设施上。你会在试图测试它的那天发现这件事。

## 注册表买到解耦，代价是安全性

当模块之间必须在没有编译期依赖的情况下互相找到对方时，团队会选择注册表：模块在启动时注册实现，使用方按接口查找。

好处是真实的：模块可以独立构建和交付，谁也不需要在编译期引用兄弟模块。

代价同样真实：一个缺失的绑定不再是编译错误，而是运行时崩溃，发生在某个人某个周二才走到的代码路径上。你用一个保证换来了灵活性。

如果你接受这笔交易，就要有意识地把风险还掉：把接口放进双方都依赖的小型契约模块；并且**在调试构建里于启动时校验整份注册表**，让缺失的绑定立刻大声失败，而不是深埋在某个用户流程里。灵活的装配没问题，无声的灵活装配才有问题。

## 迁移的方式是让新的在旧的里面长

大型应用迟早需要一门它起步时没有的技术。本能反应是重写，而重写之所以失败，原因是结构性的，不是态度问题：你瞄准的是一个移动的靶子。你重建的同时，现有产品还在不断增加功能，于是你必须追平一个仍在生长的东西，而在追平之前，你什么也交付不了。

绞杀榕的做法把它倒了过来：让新系统在旧系统内部生长，一次一屏，直到旧的可以被移除。具体需要四样东西：

1. **一条接缝**，通常是路由或导航，让同一个目的地可以由任一实现来承接。
2. **按屏追平，而不是按应用追平。** 一屏是一个可交付单元，一整个应用不是。
3. **两个运行时共存**，并明确接受[上一篇](/blog/cross-platform-boundary-zh/)里列出的那些成本。
4. **一个熔断开关。** 新实现在线上出问题时，路由要能退回去。没有它，增量迁移的风险画像和重写是一样的，只是摊薄了。

代价是有一段时间（往往是几年）要同时维护两套做法。这是“永远不需要一个切换日”所付的价钱；对一个停机或路线图停滞不可接受的产品，这笔交易是划算的。这也是为什么大型应用从外面看总显得“不一致”：你看到的是一次正在进行中的迁移，而那正是一个健康的长寿系统该有的样子。

## 不要过早买入这些

上面的一切，定价对应的是协调问题。如果你没有协调问题，它们就是开销。

对一到三个开发者来说，模块边界主要是在增加仪式：更多构建配置、更多间接层、更多要翻的地方。一个注册表严格劣于直接调用那个东西。诚实的默认选项是一份代码库，内部结构清晰，但不设强制边界。

等到某条边界开始**硌人**时再切模块：同一个区域反复出现合并冲突、构建时间已经能被感知、没人说得清归属、你想替换某个依赖却换不掉。这些都是测量结果。“我们可能会长大”不是。

昂贵的失败不是少了一个模块，而是一个小团队在维护一个大团队的架构，为一个并不存在的组织支付协调成本。


## 参考

- M. E. Conway，["How Do Committees Invent?"](https://www.melconway.com/Home/Committees_Paper.html)，*Datamation*，1968——系统结构会复刻构建它的组织的沟通结构。
- D. L. Parnas，["On the Criteria To Be Used in Decomposing Systems into Modules"](https://doi.org/10.1145/361598.361623)，*CACM* 15(12)，1972——模块应当隐藏一个可能变化的决策。
- R. C. Martin，["The Clean Architecture"](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)，2012——依赖规则：源码依赖只能向内、指向策略层。
- A. Cockburn，["Hexagonal Architecture"](https://alistair.cockburn.us/hexagonal-architecture/)，2005——端口与适配器：核心声明它需要什么，外部来供给。
- M. Fowler，["Strangler Fig Application"](https://martinfowler.com/bliki/StranglerFigApplication.html)，2004——让新系统沿着旧系统的边缘生长，直到旧系统可以被移除。
- M. Fowler，["Inversion of Control Containers and the Dependency Injection pattern"](https://martinfowler.com/articles/injection.html)，2004——依赖注入与服务定位器的对比，以及各自在可测试性上的代价。

---

*上一篇：[跨平台的边界](/blog/cross-platform-boundary-zh/) · 下一篇：[客户端安全模型](/blog/client-security-model-zh/)*

