4580 words
23 minutes
重新理解 hexagonal architecture:四层结构与两条箭头
Read in English

最近在研究 hexagonal architecture(ports and adapters)。两头一起看:一边读公司里一个已经按这个模式写好的服务,一边开了个空 repo 从零手搭一个最小版本。

下面按重要性排,不按我摸索的顺序排。前两节是整个模式的地基,其余都是它们的推论。


一、四层,和它们各自回答的问题#

先把完整的心智模型放在这里。大多数介绍会漏掉 application 这一层,而它恰恰是实践中最容易搞错的一层。

api/ inbound adapter 外面怎么进来
application/ use cases 按什么顺序做、失败了怎么办
domain/ 业务规则 + ports 什么是对的、什么算数
adapters/ outbound adapter 怎么真的存下去 / 调出去
bootstrap.py composition root 用哪个实现

每一层回答一个不同的问题,这五个问题彼此不重叠。判断一段代码该放哪,就是看它在回答哪个问题。

Application 层包在 domain 外面:inbound adapter 向下调用 application,application 编排 domain,向外只通过 domain 边缘上的一个 port;从 port 向下到 outbound adapter 的实线是运行时调用,从 adapter 反向回到 port 的虚线是依赖;composition root 独自站在一切之外,构造 outbound adapter,是唯一一个说出具体实现名字的方块

举个具体的。这是那个真实服务里的编排函数:

async def run_pipeline(raw_bytes, opts, ctx, saver: StoragePort):
document = load(raw_bytes)
pages = list(range(1, document.page_count + 1))
blocks = prepare(document, pages)
result = await analyze(blocks, opts, ctx)
if not result.usable:
return finish(None)
output = assemble(result, ctx)
validate(output, ctx)
saver.save(output)
return finish(output)

它调度所有其他模块,看起来像个中枢。但它回答的问题是「按什么顺序做」,所以它是 application,不是 domain,也不是 composition root。

它不是 composition root,因为签名里写的是 saver: StoragePort——接口。它自己不 new 任何东西,通篇没出现过一个具体类名。它是 composition root 的消费者。

两者的「知道得多」是不同性质的:composition root 知道有哪些具体实现,application 知道步骤和顺序。 前者是接线员,后者是剧本。剧本不关心插座后面接的是生产环境还是测试用的 fake。

它也不是 domain,理由需要一把更锋利的尺子。


二、地基:调用和依赖是两条独立的箭头#

上一节那个分层不是审美,它是依赖方向逼出来的。

任何两个模块之间其实有两条不同的箭头:

  • 谁在运行时调用谁
  • 谁在源码里 import 谁

平时它们总是同向,所以我们从来不区分。

inbound 那一侧确实同向。 HTTP adapter 调用 domain,也 import domain。没有矛盾。

outbound 那一侧,hexagonal 把它们掰反了。 domain 在运行时调用 FileResultSaver,却从来没有 import 过它;反而是 FileResultSaver import 了 domain 里的 port。

初看像悖论——你要调用一个东西,怎么可能不认识它?

答案就是 port:domain 认识的是「形状」,不是「实现」。

不做任何设计的默认状态是这样:

domain.py
from adapters import FileStorage # 我要调用它,所以我 import 它

这不是错误的写法,这是自然状态。 你什么都不做,它就长这样,两条箭头同向。

Hexagonal 做的唯一一件事,就是在 domain 里放一个 Protocol,让 adapter 反过来 import 它:

调用: domain ────────────→ adapter (方向没变)
依赖: domain ←──────────── adapter (被扳回来了)

Port 就是那个支点。有了它,「调用一个东西」和「依赖一个东西」才第一次分开。

Dependency Inversion 里的 inversion 是字面意思——反转的就是这条依赖箭头相对于调用箭头的方向。不是比喻。

Hexagonal 的形状:左边两个 inbound adapter(HTTP API 和 CLI)直接调用中间的 domain 六边形,那一侧没有 port;右边 domain 边缘上有两个 port 插槽,每个 port 通过一根实线箭头(运行时调用,向外)和一根虚线箭头(依赖,向内)连到它的 outbound adapter

这也解释了 ports.py 为什么在 domain/ 底下,而不是更靠外:port 是 domain 提出的要求。「我需要一个能存东西的玩意儿,长这样」——这句话是 domain 说的,adapter 只是来满足它。契约属于提要求的一方。

为什么只有一侧有 port#

既然 port 这么有用,inbound 那一侧为什么没有?

因为那一侧根本没有东西需要倒置。 被调方从来不需要 import 调用方——你写一个函数,不需要提前知道谁会调它。函数签名本身就是契约。

你只需要为「自己要调出去的东西」声明契约。

application 站在哪#

application 在六边形内部,包在 domain 外面。而且——这点很容易想错——它不连接 adapter,它连接 port。

它之所以看起来能碰到 adapter,是因为运行时手里那个对象确实是个 adapter 实例,但它只知道那是个 StoragePort。具体是谁,只有 composition root 知道过那么一瞬间。

上面那两条箭头,加了 application 之后一条都没变——这一层不参与倒置,它只是站在倒置发生的位置上面。

这也是为什么 run_pipeline 极好测:传一个假的 StoragePort 进去就行,不用 patch 任何东西。


三、application 和 domain 的分界#

第一节欠了一把尺子。这是我找到的最锋利的那把:

如果产品说「改成用两个模型各跑一次然后合并」,哪些文件要改?

只有 run_pipeline。analyze 不用改,assemble 不用改,validate 不用改——它们不知道自己被调用了几次。

只有一个文件对「跑几次」有意见。那个文件就是 use case。

反过来,「那个校验和怎么算」永远是那个算法,跟调用顺序、跟重试几次都无关。它是 domain。

domain serviceapplication service
回答什么是对的 / 什么算数按什么顺序做、失败了怎么办
粒度一步、一条规则一个完整用例
命名名词(price_book)动词(run_import)

最后那行是个意外好用的信号。我翻了公司另一个服务的目录,application/use_cases/ 底下清一色动词:run_import、push_to_warehouse、export_report。domain/services/ 底下清一色名词:price_book、usage_window、settlement_batch。

动词是有人要求系统做的一件事,名词是一个知道自己规则的业务概念。

文件夹会吸附东西#

这条分界不只是命名洁癖。

那个服务里 run_pipeline 一开始放在 domain/services/ 底下。依赖方向是干净的(它只 import domain),所以单看这一个文件,搬不搬都不影响正确性。

但接下来要加一个功能:检测到硬失败时换一个更强的模型重跑一次。这段逻辑是 application 的——「什么算失败」是业务规则,「失败了怎么办」是用例策略。

如果 run_pipeline 还待在 domain/services/,这段新代码最自然的落点就是它旁边,也就是掉进 domain 里。

一个放错位置的文件是装修,一个放错位置的文件夹会持续吸附错误的东西。

所以该动手的时机不是「等它开始 import adapter」,而是「当第二个 application 层的东西被写出来的时候」。趁只有一个文件的时候搬,成本是一次 git mv 加改一行 import。

domain 底下其实有四类东西#

domain/ 不等于 service。那底下至少有四类,只有一类是 service:

domain/
├── models.py 实体和值对象
├── schema.py 输出契约
├── ports.py 接口
└── services/ 无家可归的规则

判据是:规则如果明显属于某一个实体,就该长在那个实体上,不做成 service。

checksum_is_valid(code: str) -> bool 是典型的无家可归——它是一条关于字符串的规则,Order 拥有它有点勉强,Invoice 更不合适,所以成了 service。

反例:如果「这份文档是不是空的」被写成 document_is_empty(doc) 而不是 SourceDocument 上的一个属性,那是把行为从数据身上剥走,是贫血模型。

所以 application 串联的不只是 services,还有 models 自己承载的规则。它串的是「步骤」,步骤内部的规则住在哪,是另一个问题。


四、composition root:唯一被允许说出具体名字的地方#

依赖倒置之后,domain 和 application 全程只见接口。但程序总得跑起来——总得有人最终说出 FileResultSaver 这个名字。

在玩具 repo 里加第二个入口(CLI)之后就撞到了:cli.py 和 app.py 各自写了一遍 FileResultSaver(...)。两个入口,两份「用哪个实现」的决定。第三个入口就是三份。

于是把这个决定抽进一个文件:

bootstrap.py
def make_saver() -> ResultSaverPort:
return FileResultSaver("results.json")

抽完之后做个验证:把 make_saver() 改成返回 InMemoryResultSaver()。两个入口照常工作,文件不再产生,git status 里只有一个文件变化。

抽出 composition root 前后对比:之前 CLI 和 HTTP API 各自构造 FileResultSaver,是两条重复的 wiring;之后两个入口都调用 composition root,只有它一条边通到 FileResultSaver

它在最外层,这一点很反直觉——它知道所有东西,感觉应该在中心。

但层级是按「谁依赖谁」排的,不是按重要性。composition root 依赖了每一个具体实现,所以任何实现改了都可能波及它。它必须站在最边缘,否则它的改动会往内层传。

一句话判定法:把这份代码当作 library 发布给别人用,哪一部分必须删掉?那就是 composition root。 library 从不自带 composition root,由使用它的那个 application 提供。

「知道全局」不等于「在中心」。知道得越多,位置越外。

三层同心矩形:最内层是 domain + ports,不依赖任何东西;中间一层是 adapters(inbound 和 outbound),依赖 domain,彼此之间也可以互相依赖;最外层是 composition root,依赖所有人,没有人依赖它

它不是一层,是一个时刻#

叫它「一层」其实不准确,因为它常常只有一个文件。

分层是一个静态问题——谁能 import 谁,看源码就知道,编译期就定死了。而 composition root 是一个动态问题:具体类型在哪一个时刻被说出口。

它根本不是一层,它是一个时刻。

它必须存在的根本原因是:依赖倒置不可能无限倒置。 总得有人最终说出那个名字,否则程序跑不起来。composition root 就是被允许说出口的那唯一一处,代价是它只能在最开始说一次,说完就退场——之后全程只有接口在流动。

这跟上面那个 library 判定法是同一件事的两面。library 之所以不能自带 composition root,正是因为「什么时候选实现」这个决定权属于使用它的人,不属于库。


五、这个模式保证不了什么#

前面四节讲的都是它做到了什么。这一节讲它做不到什么,因为这部分几乎没人写,而它决定了你要不要额外做点别的。

Protocol 只保证形状,保证不了行为#

考虑这两个类:

class NullResultSaver(ResultSaverPort): # 故意什么都不做
def save(self, n, is_odd) -> None: pass
class BrokenFileSaver(ResultSaverPort): # 写文件写漏了,是个 bug
def save(self, n, is_odd) -> None: pass

代码可以一模一样。 形状相同,意图相反。type checker 两个都放行,运行时两个都不崩。

Protocol 让实现可替换,不让实现正确。 区分它们的只能是测试——给 null adapter 写的测试断言「目录仍然是空的」,给 file adapter 写的测试断言「文件里出现了这三行」。意图是靠断言表达的。

还有一件事:Protocol 在运行时是零成本、零作用的。往 composition root 里塞一个完全不符合 port 的对象,Python 在注入那一刻毫不在意,一路跑到 domain 调用它才抛 AttributeError——症状和病因隔了两层。它 100% 服务于 static type checker 和读代码的人。CI 里不跑 mypy 或 pyright,那些 Protocol 就退化成纯文档。

没有任何工具在守依赖方向#

把玩具 repo 的 domain 改成「port 死掉」的样子,三处改动:

from hex_lab.adapters import FileResultSaver # ① domain 直接 import 具体实现
def check_and_save(n: int, saver: FileResultSaver | None = None) -> bool: # ② 标注写具体类
saver = saver or FileResultSaver("results.json") # ③ domain 自己构造
result = _is_odd(n)
saver.save(n, result)
return result

port.py、adapters.py、bootstrap.py 一个字都没动。然后跑三条命令。

程序正常运行。测试全部通过。

uv run --with mypy mypy src/
Success: no issues found in 7 source files

mypy 全绿。

架构违规在类型系统里是完全不可见的。 mypy 检查的是「A 能不能当 B 用」,它从来不检查「A 该不该认识 B」。没有任何 type checker 会在你把依赖箭头指反的时候报警。

测试也抓不到——那几个测试都显式注入 saver,从来没走到那个默认值分支。

这条边界只能靠人守。 真要自动化,得另配 import-linter 这一类专门声明「这一层禁止 import 那一层」的工具,那是额外一层,不是 type checker 白送的。

左右对比:左边 port 活着——domain 依赖 StoragePort,FileStorage 实现 StoragePort,composition root 注入 FileStorage;右边 port 死了——domain 直接 import 并构造 FileStorage,绕过了 StoragePort,那个 port 定义在那里但零引用,composition root 也没人调用

而坏掉之后的手感非常具体:ResultSaverPort 掉到只剩定义处一个引用,成了死代码;bootstrap.py 没人调用,被彻底架空;想把默认行为改成「不落盘」,必须打开那个纯业务逻辑文件;而且 "results.json" 这个基础设施配置,现在硬编码在业务层里。


六、什么时候不该用#

这个反转不是免费的,成本有三条。

多一个文件和一个类型。 显而易见的那条。

inbound adapter 要搬运一个自己完全不用的对象。 看 cli.py 在干什么:

saver = make_saver()
result = check_and_save(n, saver)

它自己一次都没调用过 saver.save()。这在别的语境里是个有名的坏味道(middle man / tramp data),但在这里是故意的——一旦让 domain 自己去拿,就变回上一节那个「port 死掉」的版本。搬运一个自己不用的依赖,既是解耦的代价,也是解耦的机制本身。

读 domain 代码时看不出运行时到底在调什么。 多了一层间接,这是真实的认知成本。

收益只有两条:能换实现,测试时能隔离 I/O。

所以不是每个依赖都值得做 port。 一个永远不会换、也不需要在测试里隔离的依赖,加 port 就是纯成本。

判据压成一句话:port 的价值和「你是否真的会用到第二个实现」严格挂钩。

一个真实的对照#

回到开头那个我读的服务。它定义了两个 port。用上面这把尺子 grep 一遍整个仓库:

PortA → 全仓库出现 1 次:它自己的定义。零引用。
PortB → 出现 5 次:跨三个文件的类型标注 + 依赖注入。

PortA 完全是死代码。domain 层的两个 service 直接 import 了具体的 adapter 类,函数签名标注的也是具体类,还在函数体里自己构造默认值——跟上一节手动造的那个坏版本,四个症状逐条对应。

而 PortB 有三个实现:一个生产实现,两个测试替身。

差别不在设计水平,在于测试有没有强迫你造出第二个实现。

PortA 背后是一个 PDF 解析库。没人需要换掉它,测试也可以直接用真实文件,所以第二个实现从来没被造出来,那个反转自然就退化回默认状态了。

为什么 LLM 调用是 port 最好的场景#

如果你在做 AI 相关的开发,这个模式对你的收益比对大多数人都大,理由是三条这个场景独有的:

第一,这个依赖是真的会换,而且换得频繁。 provider、接入方式(直连 API vs 云厂商托管 vs 内部网关)、model ID——每几个月就动一次。对比一下 PDF 库,你三年也不会换一次。

第二,测试逼着你必须有第二个实现。 真实 LLM 调用慢(几秒)、花钱、输出不确定。任何一个需要跑得快的测试都不可能打真实 API。注意这个因果——不是「我们设计得好所以有两个实现」,而是「测试没法用真实调用,所以第二个实现是被逼出来的」。

第三,你需要钉住输出才能测下游。 LLM 之后还有一大串确定性逻辑:解析、schema 校验、结果组装、修复畸形 JSON。这些逻辑本身完全可测,但前提是你能塞一个固定的响应进去。port 就是那个注入点。

有个坑值得单独提:很容易把 port 设计成某一家 provider 的形状。 如果你的 port 签名里出现了只有一家有的概念——某种特定的 content block 结构、某个厂商独有的参数名——那它就不是 port,是 wrapper。换 provider 的时候 domain 照样要改。

自检方法很简单:把这个 port 的签名拿去给另一家 provider 实现,签名需要改吗? 需要改,说明抽象漏了。

还有一个要提前决定的分界:retry、backoff、限流放在 port 的哪一边? 放 domain 里,每换一个 provider 都得重写一遍;放 adapter 或者网关里,domain 就完全不知道有重试这回事。这个决定越早做越好。


最后#

如果只留一句,是第二节那句:

任何两个模块之间有两条独立的箭头——谁调用谁,和谁 import 谁。平时它们同向,所以我们从不区分;hexagonal 做的全部事情,就是把 outbound 那一侧的第二条箭头掰回来。

四层结构是这句话的推论:port 是支点,composition root 是唯一被允许说出具体类名的那个时刻,application 是站在倒置之上的剧本。

而它最反直觉的地方在第五节:这个模式最核心的机制——依赖的方向——在运行时不可见,测试抓不到,type checker 也不报错。 所以它没法靠工具守住,只能靠人。