最近在研究 hexagonal architecture(ports and adapters)。两头一起看:一边读公司里一个已经按这个模式写好的服务,一边开了个空 repo 从零手搭一个最小版本。
下面按重要性排,不按我摸索的顺序排。前两节是整个模式的地基,其余都是它们的推论。
一、四层,和它们各自回答的问题
先把完整的心智模型放在这里。大多数介绍会漏掉 application 这一层,而它恰恰是实践中最容易搞错的一层。
api/ inbound adapter 外面怎么进来application/ use cases 按什么顺序做、失败了怎么办domain/ 业务规则 + ports 什么是对的、什么算数adapters/ outbound adapter 怎么真的存下去 / 调出去bootstrap.py composition root 用哪个实现每一层回答一个不同的问题,这五个问题彼此不重叠。判断一段代码该放哪,就是看它在回答哪个问题。
举个具体的。这是那个真实服务里的编排函数:
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 认识的是「形状」,不是「实现」。
不做任何设计的默认状态是这样:
from adapters import FileStorage # 我要调用它,所以我 import 它这不是错误的写法,这是自然状态。 你什么都不做,它就长这样,两条箭头同向。
Hexagonal 做的唯一一件事,就是在 domain 里放一个 Protocol,让 adapter 反过来 import 它:
调用: domain ────────────→ adapter (方向没变)依赖: domain ←──────────── adapter (被扳回来了)Port 就是那个支点。有了它,「调用一个东西」和「依赖一个东西」才第一次分开。
Dependency Inversion 里的 inversion 是字面意思——反转的就是这条依赖箭头相对于调用箭头的方向。不是比喻。
这也解释了 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 service | application 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(...)。两个入口,两份「用哪个实现」的决定。第三个入口就是三份。
于是把这个决定抽进一个文件:
def make_saver() -> ResultSaverPort: return FileResultSaver("results.json")抽完之后做个验证:把 make_saver() 改成返回 InMemoryResultSaver()。两个入口照常工作,文件不再产生,git status 里只有一个文件变化。
它在最外层,这一点很反直觉——它知道所有东西,感觉应该在中心。
但层级是按「谁依赖谁」排的,不是按重要性。composition root 依赖了每一个具体实现,所以任何实现改了都可能波及它。它必须站在最边缘,否则它的改动会往内层传。
一句话判定法:把这份代码当作 library 发布给别人用,哪一部分必须删掉?那就是 composition root。 library 从不自带 composition root,由使用它的那个 application 提供。
「知道全局」不等于「在中心」。知道得越多,位置越外。
它不是一层,是一个时刻
叫它「一层」其实不准确,因为它常常只有一个文件。
分层是一个静态问题——谁能 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 resultport.py、adapters.py、bootstrap.py 一个字都没动。然后跑三条命令。
程序正常运行。测试全部通过。
uv run --with mypy mypy src/Success: no issues found in 7 source filesmypy 全绿。
架构违规在类型系统里是完全不可见的。 mypy 检查的是「A 能不能当 B 用」,它从来不检查「A 该不该认识 B」。没有任何 type checker 会在你把依赖箭头指反的时候报警。
测试也抓不到——那几个测试都显式注入 saver,从来没走到那个默认值分支。
这条边界只能靠人守。 真要自动化,得另配 import-linter 这一类专门声明「这一层禁止 import 那一层」的工具,那是额外一层,不是 type checker 白送的。
而坏掉之后的手感非常具体: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 也不报错。 所以它没法靠工具守住,只能靠人。