如果你花时间读过软件架构方面的文章,一定见过反复出现的三个名字:Hexagonal Architecture、Onion Architecture 和 Clean Architecture。它们各有自己的图示、自己的术语,以及各自热情的拥趸。一口气读完三篇,感觉就像在看三个人争论用哪种方式说同一句话。
诚实的版本是这样的:它们是同一个思想,在不同时期被独立发现,用略有不同的图形画出来。每一种方案都在另外两种没有大声强调的地方增加了一点细微的差别。一旦你看到共同的北极星,差异就变得显而易见——你可以选择团队已经最熟悉的那套词汇。
三者共享的同一个思想
这三种架构都建立在同一条规则之上,有时被称为依赖规则:源码中的依赖关系必须永远指向内部,指向业务规则,而不是指向外部的框架、数据库或 I/O。
可以把它想象成重力。你的 domain——让产品值得使用的核心逻辑——坐落在中心。围绕它的一切(数据库、Web 框架、支付 SDK、邮件服务商、CLI 工具)都运行在外圈。外层被允许知道内层的存在。内层则被严格禁止知道外层的存在。
为什么这很重要?因为变化最频繁的东西恰恰在外面:你更换支付服务商、迁移到新数据库、重写前端、增加移动端。如果业务规则与这些细节纠缠在一起,每次变化都会涟漪式地波及你最在乎的代码。如果它们被隔离,一次供应商更换就是边缘处的局部替换——核心根本不会察觉。
Alistair Cockburn、Jeffrey Palermo 和 Robert C. Martin 各自从不同角度发现了这个模式。Cockburn 画了一个六边形,把连接点叫做“ports”。Palermo 画了同心圆环,称之为“layers”。Martin 画了同心圆,并为每层起了严格的名字。每张图中的箭头方向都指向同一个方向:内部。
源码依赖指向内部。Domain 定义接口;外部世界实现它们。在任何地方翻转这个方向,架构就被破坏了——无论你用的是哪个名字。
Hexagonal Architecture(Ports & Adapters)
Alistair Cockburn 于 2005 年以 Ports & Adapters 为别名提出了 Hexagonal Architecture。核心洞见是对称性:你的应用有两类外部——驱动它的东西(用户点击按钮、测试运行器、定时任务)和被它驱动的东西(数据库、邮件服务商、支付 API)。两侧都通过 ports——domain 拥有的接口——以及 adapters——把真实技术连接到这些接口的具体实现——进行通信。六边形的形状只是一个视觉提示:应用有很多这样的 port,而不仅仅是“上面”和“下面”。如果你想了解 ports、adapters、主/次两侧和代码示例的完整讲解,本系列的上一篇——Ports & Adapters——从第一原理开始讲透了这些内容。这里的核心结论是:Hexagonal 是三者中最对称的:它以同等的严格程度对待传入调用和传出调用,它的词汇(port、adapter、driving、driven)也是最具体、最机械的。
Onion Architecture
Jeffrey Palermo 于 2008 年描述了 Onion Architecture。他保留了同样的依赖指向内部的规则,但选择了不同的图形:同心圆环,就像洋葱的层次。最内圈是 Domain Model——你的核心实体和值对象,纯粹的业务概念,没有任何技术痕迹。围绕它的是 Domain Services 圈,这里存放协调多个 domain 对象的逻辑,但仍然对外部世界一无所知。再往外是 Application Services 圈,use case 在这里存活,编排工作在这里发生。最外层是基础设施和 UI 细节——数据库、Web 框架、外部 API。
Onion 强调了另外两种方案没有大声说出的一点:domain model 与 domain services 的区分。Palermo 是为 .NET 团队写的,这些团队经常混淆“代表一个 Order 的东西”和“处理 Orders 的 service”——这是一个真正有用的分离。Onion 也让分层变得非常直观:你可以看一眼任何类的 import 列表,立即根据它允许 import 的内容判断它属于哪个圈。如果一个 domain service 试图 import 一个数据库库,它显然待错了地方。
Clean Architecture
Robert C. Martin(Uncle Bob)于 2017 年发表了 Clean Architecture,综合了他多年来一直在完善的思想。他画了同样的同心圆,但给每一个圆起了明确的名字和明确的用途。从内到外:Entities(企业级业务规则——即使没有计算机也依然成立的东西)、Use Cases(应用特定的业务规则——软件究竟要为用户做什么)、Interface Adapters(controllers、presenters、gateways——在 use case 世界和外部世界之间做转换的代码)、Frameworks & Drivers(最外圈——数据库、Web 框架、UI 库、设备驱动)。
Clean Architecture 最显著的补充是明确命名的 Use Cases 层。在许多代码库中,“use case”是一个非正式的概念——你可能有一个做十件事情的 UserService。Martin 坚持把每个 use case 做成一个一等的、命名的产物:一个类或函数,其名称字面上就说明它做什么(PlaceOrder、RegisterUser、GenerateInvoice)。他把这称为“尖叫式架构”——当新开发者打开项目结构时,他们应该立刻看到系统做什么,而不是它用了什么框架。Martin 还正式化了 Boundaries 的概念——每个圈之间都有明确的接口边界——以及跨越边界的数据必须是可序列化的、简单的数据结构,而不是携带隐藏行为的富 domain 对象。
Martin 的“尖叫式架构”这个短语表达了一个简单的愿望:打开项目中的任何一个文件夹,你应该立刻理解业务意图,而不是技术栈。一个叫 use-cases/ 的文件夹里放着 PlaceOrder.ts 和 RegisterUser.ts,它的意图在尖叫。而一个叫 controllers/ 的文件夹装着其他所有东西,什么也没说出来。
并排比较:词汇对照表
读完这三种方案后最大的实际障碍,是它们用不同的词指代重叠的概念。以下是翻译对照表:
| 概念 | Hexagonal(Cockburn) | Onion(Palermo) | Clean(Martin) |
|---|---|---|---|
| 最内层的核心 | Domain(没有具体圈名) | Domain Model 圈 | Entities 圈 |
| 业务 use case | 六边形内部的应用逻辑 | Application Services 圈 | Use Cases 圈(明确命名) |
| 转换层 | Adapters(driving + driven) | Infrastructure 圈 | Interface Adapters 圈 |
| 最外层技术 | 六边形外部(HTTP、DB 等) | UI / Infrastructure 圈 | Frameworks & Drivers 圈 |
| 连接缝 | Port(由 domain 拥有的接口) | 每个圈边界处的隐式接口 | Boundary(每个跨越处的显式接口) |
| 主要强调 | 对称 port;可测试性;每个 adapter 的可替换性 | 分层圆环;domain model 与 domain services 的区分 | 明确命名的 use case;尖叫式结构;严格的数据边界 |
| 最适合…… | 有大量集成需要独立替换的场景 | 有非平凡服务编排的富 domain model | 需要共享词汇和“尖叫式”文件夹结构的大型团队 |
它们在实践中真正不同的地方
共同规则掩盖了真实的侧重差异,这些差异在你坐下来写代码时会显现出来。
Hexagonal 是最机械的。它给你精确的、命名的槽位:这里是驱动型 adapter,这里是它调用的 port,这里是处理它的 use case,这里是 output port,这里是被驱动型 adapter。如果你忠实地遵循这些槽位,结构几乎会自动生成——而且因为两侧是对称的,防止数据库悄悄进入 domain 的规则,与防止 domain 泄漏到 HTTP 层的规则是同一套。代价是 Hexagonal 不告诉你如何组织六边形的内部——它对核心是否应该有子层(如 domain model 与 domain services)保持沉默。
Onion 是最有层次感的。Hexagonal 画一道边界(六边形内部与外部),Onion 在核心内部画了好几圈。当你的 domain 足够大、需要自己的内部结构时,这很有价值——区分纯实体、协调它们的 domain services,以及编排 use case 的 application services。圆环的比喻在视觉上很直观,但要严格遵守它就需要更多的自律:赶进度时很容易跨圈取巧,而 Onion 没有像 Hexagonal 的 port/adapter 词汇那样给你机械性的检验。
Clean 是最规范的。Martin 给每一层和每一个概念都起了名字,这既是优点,也是争议的来源。优点在于团队共享了无歧义的词汇——当两位工程师讨论“use case boundary”时,每个人都知道那是什么意思。争议的来源在于,规定好的结构对小型应用来说可能显得繁重:为每个功能创建明确的 InputBoundary、OutputBoundary 和 UseCase 接口,在构建原型时仪式感太重了。Clean Architecture 还坚持在每个圈边界处使用纯数据传输对象(DTO),这增加了代码量,但让边界非常清晰。规模扩大后,当防止“富对象跨层泄漏”成为真实问题而不是假设时,回报才会显现。
简而言之:Hexagonal 给你最清晰的机械指导,告诉你如何连接各部分。Onion 给你最清晰的 domain内部分层。Clean 给你最清晰的词汇和最有主张的文件夹结构。现实世界的大多数代码库在不知不觉中混合了三者。
该选哪一个
因为三者共享相同的核心规则,在它们之间做选择实际上是词汇、团队规模和具体场景需要哪种细微差别的问题。
| 团队与阶段 | 建议方案 | 原因 |
|---|---|---|
| 独立开发者 / 早期创业 | Hexagonal,轻量应用。围绕数据库和最易变的供应商建立一两个 port。 | 速度比完整性更重要。几个接口带来快速测试和替换路径;完整的仪式可以等等。 |
| 小团队,成长期(约 Series A) | Onion 或 Hexagonal,对所有 I/O 使用明确的 port 接口。 | 测试开始成为瓶颈,新员工需要快速理解 domain。圆环标签(或 port 名称)让结构一目了然。 |
| 中型(多个小队) | 以 Clean Architecture 词汇作为团队间的共同契约。 | 边界模糊时小队会相互踩脚。给每个 use case 和每条边界命名,让团队能够在清晰的缝合处独立工作。 |
| 大型企业 | 三者结合,有明确边界、版本化契约,以及每个 port 对应多个 adapter。 | 监管要求、遗留系统和多供应商采购,都需要只有完整 Clean + Hexagonal 边界才能提供的明确性。 |
几个与这张表对应的真实场景:
一家金融科技初创公司(8 名工程师)使用了两个 port——PaymentGateway 和 LedgerRepository——其他一切都直接调用。十八个月后他们更换了支付服务商,只需要写一个新的 adapter 类和改一行连接代码。其余代码库未受影响。这就是 Hexagonal 最精简、最有效的样子。
一家中型 SaaS 公司(60 名工程师)把 Onion 圆环标签作为代码审查规则:“domain model 圈里的任何东西都不允许 import 圈外的东西,没有例外”。新工程师在第一次 PR 审查时就理解了边界策略。圆环词汇成为了一种快捷语——“你在这里跨圈了”就是一条完整的审查意见,人人都懂。
一家企业级银行(数百名工程师,20 年历史的代码库)使用了完整的 Clean Architecture 词汇,有命名的 UseCase 接口、每个圈之间明确的 DTO 对象,以及每个 output port 两个 adapter(旧主机 + 新核心银行系统并行运行)。仪式感很重,但审计员和合规团队可以像读规格说明书一样读懂架构,各小队可以独立部署,因为边界是代码中真实的缝合处。
唯一真正糟糕的结果是在同一个团队中混用三个名字,却没有共同的词汇表。如果一半人叫它“port”,另一半叫它“boundary”,代码审查就变成了翻译练习。选一套词汇,写下来,坚持用。底层规则——依赖指向内部——才是最重要的。
核心要点
- 一条规则,三张图。Hexagonal、Onion 和 Clean Architecture 都在执行同一条依赖规则:源码依赖指向内部,指向业务规则,而不是指向框架或 I/O。
- Hexagonal(Cockburn,2005)给你最机械的指导:ports 和 adapters,对称的 driving 侧和 driven 侧。有大量集成需要替换时最适用。
- Onion(Palermo,2008)在 domain 内部增加了明确的同心圆环,将 domain model 与 domain services 和 application services 分开。domain 足够大、需要内部结构时最适用。
- Clean(Martin,2017)给每一层命名,让 use case 成为一等产物。增加了“尖叫式架构”——文件夹结构声明业务意图。需要共享词汇和每个边界处严格 DTO 的大型团队最适用。
- 它们是叠加的,不是竞争的。大多数成熟的代码库混合了三者:Hexagonal 的 port/adapter 接线、Onion 的 domain 内部圆环规律、Clean 的 application 层 use case 命名。
- 按需取用。两个 port 的 Hexagonal 设置对初创公司已足够。完整的 Clean 仪式在企业规模下才物有所值。不要为还不需要的词汇付出代价。
- 每个团队选一套词汇并写下来。名字本身不如一致性重要。
本系列的下一篇文章将深入探讨让这三种方案成为可能的机制:Dependency Injection & Inversion of Control——在运行时把 adapter 连接到 port 的实际接线,而无需 domain 触及任何框架。