你一定有过这种感受:一个本该花一下午完成的功能,结果耗掉了三天——只因为那个“小改动”(改一下发送邮件的方式)牵连了十几个文件。支付代码知道数据库的存在,数据库代码知道 Web 请求的细节,而夹在中间、几乎被淹没的,才是真正让你的产品成为你产品的那些业务规则。
Ports & Adapters(你也会听到它叫 Hexagonal Architecture——六边形架构,由 Alistair Cockburn 于 2005 年提出)是一种代码组织方式,专门用来避免上面这种情况。它不是框架,不需要安装,适用于任何语言。说白了就是一个想法穿了件好听的外衣。我们来一步一步拆开它,配上图解和真实示例,读完你就能清楚地知道什么时候该用它——什么时候不该。
问题所在:代码“长歪了”
大多数应用最初都一样讨人喜欢——一个 controller 调用一个 service,service 和数据库及几个第三方服务打交道,皆大欢喜。麻烦在于时间一长会发生什么。下面这段 checkout 逻辑只有几个月的历史,已经让人头疼了:
// checkout.ts — 所有东西搅在一起
class OrderService {
async checkout(cart: Cart) {
const db = new PgClient(process.env.DB_URL) // 绑定了具体的数据库
const stripe = new Stripe(process.env.STRIPE_KEY) // 绑定了具体的第三方服务
const sg = new SendGrid(process.env.SENDGRID_KEY) // 又一个第三方服务
// 真正的业务规则被埋在各种第三方调用之间……
const total = cart.items.reduce((s, i) => s + i.price, 0)
await stripe.charges.create({ amount: total, source: cart.token })
await db.query('INSERT INTO orders …')
await sg.send({ to: cart.email, template: 'order-ok' })
}
}
这里没有哪行代码是“错的”。但业务逻辑(订单是什么、何时有效、“已确认”意味着什么)和三个第三方服务以及数据库驱动紧紧缠绕。你没法在没有真实数据库的情况下测试这些规则。你没法不“动手术”就换掉 SendGrid。新来的队友读这段代码,会先了解 Stripe,然后才了解你的订单。
Ports & Adapters 回答的是这一个问题:怎样把赚钱的部分(业务规则)与早晚要换掉的部分(数据库、第三方服务、框架、UI)分开?
核心思想,一张图说清楚
把业务规则——domain(领域)——放在中心。禁止它引入任何框架、数据库驱动或第三方 SDK。然后让外部世界只通过 port(接口)与它对话,由 adapter(适配器)在另一侧处理那些乱糟糟的真实世界细节。
整体形状就是这样。人们画六边形而不是圆形,有个有趣的原因:六条边提醒我们,应用有很多进出方式(Web UI、API、测试、定时任务、数据库、消息队列……),而不只是“上面”和“下面”。数字六本身没有意义——不要去数你的 port 数量。
核心词汇,用大白话说清楚
三个词就能说清楚所有事情。下面不用术语来解释:
- Domain(领域/核心):你的业务规则和概念——
Order、Money、“购物车里没有商品就不能结账”。纯逻辑。读起来应该像在描述你的业务,而不是你的技术栈。 - Port(接口):一个由 domain 定义的接口,描述它需要或提供的能力——“我需要能
save(order)”或“我需要能charge()一笔付款”。Port 是一个承诺,用 domain 的语言写成,里面看不到任何第三方服务的名字。 - Adapter(适配器):用真实技术实现某个 port 的具体代码——比如
PostgresOrderRepo、StripeGateway。Adapter 是 Stripe、Postgres、React 被允许出现的地方。
Port 还分两种类型——这是唯一值得深入理解的细节:
- 驱动型(主)adapter 在左侧,调用你的应用:Web controller、CLI 命令、测试。它们使用 input port(你的 use-case 接口)。
- 被驱动型(次)adapter 在右侧,被你的应用调用:数据库、邮件服务、支付 API。它们实现 domain 定义的 output port。
如果某样东西主动发起与你的应用的对话,它就是驱动方。如果是你的应用主动发起,另一方就是被驱动方。用户点击“购买”是驱动方;支付服务商是被驱动方。
具体示例:改造前后对比
来把那段 checkout 代码理清楚。首先,用你业务中的语言写下 domain 需要的东西——port——不允许出现任何第三方名称:
// ports.ts — domain 的语言词汇表,禁止出现任何第三方名称
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Notifier { orderConfirmed(order: Order): Promise<void> }
现在 use case 只依赖这些承诺,别无其他。大声读出来:听起来像在描述结账流程,而不是在列举第三方服务清单。
// checkout-service.ts — 只依赖 port,绝不依赖第三方服务
class CheckoutService {
constructor(
private payments: PaymentGateway,
private orders: OrderRepository,
private notify: Notifier,
) {}
async checkout(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // 纯业务规则
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.notify.orderConfirmed(order)
return order
}
}
最后,真实的第三方服务藏在实现了各自 port 的 adapter 里。换掉任何一个都是局部改动——而在测试中,你可以用 fake 替换它们,在以前一次网络调用所需的时间里跑完成千上万个测试用例:
// adapters/*.ts — 第三方服务在这里,藏在它所实现的 port 背后
class StripeGateway implements PaymentGateway { /* Stripe SDK */ }
class PostgresOrderRepo implements OrderRepository { /* SQL 在这里 */ }
class SendGridNotifier implements Notifier { /* SendGrid */ }
// 测试时用内存 fake 替换真实服务——无需网络,毫秒级运行:
const service = new CheckoutService(new FakePayments(), new InMemoryOrders(), new NullNotifier())
注意发生了什么变化:Stripe、Postgres 和 SendGrid 现在是边缘的细节。真正让你赚钱的东西坐镇中央,清晰可读、易于测试,完全不知道那些第三方服务的存在。
让一切成立的那一条规则
如果只记住一件事,请记住这个:源码中的依赖关系永远指向内部。外层(adapter、框架、I/O)可以知道内层。而中心的 domain 被允许对外面的一切一无所知。
这就是为什么你可以替换整个 Web 框架,或者从 MySQL 迁移到 DynamoDB,而 domain 对此毫不知情。依赖箭头永远不会从核心指向外部,所以边缘的改动无法渗透进来。(如果你听说过依赖倒置原则——SOLID 中的“D”——这就是它在实践中的样子:domain 定义接口,adapter 遵从它。)
驱动方 vs 被驱动方,落地理解
主/次之分在日常工作中为什么重要?因为它告诉你谁来定义接口。
- 对于被驱动型 port(数据库、邮件),domain 定义接口,adapter 按要求去实现。这就是你能随意更换第三方服务的原因。
- 对于驱动型 port(你的 use case),应用定义接口,外部世界(HTTP、CLI、测试框架)来调用它。这就是同一套逻辑今天能跑 REST API、明天能跑 gRPC 端点或定时任务,而业务规则一行不改的原因。
一个实用的检验方法:一种全新的接入方式(比如 Slack 机器人)应该只是一个新的驱动型 adapter。一个全新的第三方服务(比如从 Stripe 换到 Adyen)应该只是一个新的被驱动型 adapter。如果任何一方迫使你修改 domain,说明某处发生了“泄漏”。
在不同公司规模下长什么样
很多建议在这里走偏——把架构当成“一刀切”的方案。合适的 Ports & Adapters 用量在很大程度上取决于你公司所处的阶段。以下是诚实的版本:
| 公司阶段 | 它解决的痛点 | 建议用量 |
|---|---|---|
| 独立开发者 / 早期创业 | 你仓促选了 SQLite 和某个支付服务,现在担心被死死绑定在上面。 | 最多一两个 port——通常是数据库和支付。其他的直接调用就好。这个阶段速度比纯粹更重要。 |
| 小型 / 成长期(约 Series A) | 测试打到真实数据库和第三方沙箱,又慢又不稳定,开发者干脆不跑了。 | 给所有 I/O 套上 port。用 fake 让单元测试在毫秒内跑完,新同事一天就能理解 domain。 |
| 中型 / 规模化 | 多个团队互相踩脚;一次“换掉 SendGrid 省钱”的决定变成长达一个月的重写。 | Port 成为团队之间的契约。换一个第三方服务只需要写一个新的 adapter,不是一次迁移。 |
| 大公司 / 企业级 | 遗留系统、区域性第三方服务、审计、以及要求“不得单一供应商锁定”的采购规范。 | 每个 port 对应多个 adapter(旧的 + 新的),strangler-fig 渐进迁移,供应商独立性作为书面要求写进合同。 |
三个简短的真实故事
小型电商(10 人团队)。他们把邮件功能藏在一个只有一个方法的 Notifier port 后面——“以防万一”。两年后邮件成本飙升;从 SendGrid 迁移到 Amazon SES 只是写了一个新 adapter 加改了一行连接代码,一个下午搞定。订单逻辑——他们的核心资产——没有被碰过。
规模化中的金融科技公司(约 120 名工程师)。支付需要在某个国家走本地服务商、在另一个国家走国际服务商,每笔交易运行时动态决定。因为 PaymentGateway 是一个 port,“用哪个服务商”变成了隐藏在接口背后的路由决策。合规审计员也很满意:这道边界清晰、可测试,一眼就能看到。
大型银行(1000+ 名工程师)。一套运行了 20 年的主机系统无法一夜之间被替换。他们定义了一个 AccountLedger port,写了两个 adapter——一个与旧主机通信,一个与新的核心银行系统通信——然后逐步迁移流量(“绞杀者无花果”模式)。各团队基于这个 port 开发新功能,而迁移在他们看不见的地方悄然进行。
Ports & Adapters 与经典分层架构的对比
大多数团队来自传统分层架构(UI 在上、service 在中、数据库在下)。以下是两者的比较:
| 问题 | 经典分层(UI → service → DB) | Ports & Adapters |
|---|---|---|
| 谁依赖谁? | 自上而下:每一层依赖下面那层,最终落到数据库。 | 所有东西依赖内部,落到 domain。数据库只是另一个插件。 |
| 不连数据库能测业务规则吗? | 通常不行——service 直接打数据库。 | 可以——注入一个假的 repository,不需要数据库,不需要网络。 |
| 换一个第三方服务? | 要动 service 层,并向外扩散影响。 | 写一个新 adapter,domain 完全不变。 |
| 业务规则住在哪里? | 通常散落在 service 和数据库之间。 | 集中在 domain,用业务语言表达。 |
| 成本 | 初期仪式感低,但随着时间推移把你锁定在技术栈上。 | 前期多几个接口;随着变化加速,逐渐物有所值。 |
最核心的差别在于箭头方向。在分层代码中,最终所有东西都依赖数据库。在 Ports & Adapters 中,数据库依赖你。这个倒转,就是全部的意义所在。
什么时候用(以及什么时候不用)
好的架构是把精力匹配到风险。以下情况适合使用 Ports & Adapters:
- 应用有真正的业务规则值得保护(不只是对一张表做 CRUD)。
- 你预期在应用生命周期内会更换或新增集成——第三方服务、数据库、接入渠道。
- 测试速度和可靠性很重要,你希望有快速、确定性的单元测试。
- 多个团队或较长的生命周期使得清晰的边界物有所值。
以下情况应当持怀疑态度,或者只是非常轻量地应用:
- 这是一个薄薄的 CRUD 应用或一次性原型;多余的抽象层只是额外开销。
- “domain”基本上就是你的数据库 schema;没有什么需要保护的。
- 你是独自开发、需要快速交付,以后重写的成本比现在搞这些仪式的成本还低。
把一个毫无价值的 CRUD 端点包裹在三层接口里,并不会让它“干净”——只会让它更难读。架构是你为可变更性付出的代价。如果没有什么会变,你就是在为虚无买单。
常见错误(以及简单的修复方法)
- Port 泄漏。如果你的
PaymentGateway返回一个Stripe.Charge对象,第三方服务就通过 port 泄漏出来了。修复:port 只使用你自己的类型(Receipt、Money)。 - 贫血型 domain。如果所有真实逻辑都在 adapter 里,domain 只是数据包,说明边界画错了地方。修复:把规则推进核心。
- 一个巨型 port。一个有 40 个方法的
IRepository不是边界,是个杂物抽屉。修复:用小的、有明确目的的 port(OrderRepository、InventoryRepository)。 - 把 DTO 和 domain 模型混淆。你通过 HTTP 传输的数据形状不是你的 domain 实体。修复:让 adapter 负责在传输格式和 domain 之间做转换。
- 给所有东西都建接口。不是每个类都需要 port——只有跨越边界的东西才需要(I/O、第三方服务、接入机制)。修复:感受到痛点再加 port,不要提前“预防性”地加。
周一就能开始的做法
你不需要重写任何东西。只需在最痛的地方引入一道接缝,然后停下来:
- 1. 找出最让你痛苦的依赖。通常是数据库或某个不稳定的第三方服务。
- 2. 写下你希望拥有的接口。用业务语言:
save(order),而不是execSql(...)。 - 3. 把第三方代码移到一个实现该接口的 adapter 后面。其他什么都先不动。
- 4. 注入 adapter,而不是在代码里直接构造它,然后为测试写一个 fake。
- 5. 感受那份轻松,然后重复——但只在频繁变动或测试痛点真正值得再加一个 port 的地方。
不出一周,你就会拥有围绕最棘手集成的快速测试,以及一个开始读起来像你业务的 domain。这就是全部的收益,可以增量地获得。
核心要点
- 一个核心思想:业务规则居中,技术细节在边缘,双方只通过接口交流。
- Port = 由 domain 定义的接口。Adapter = 真正的实现(Stripe、Postgres、React)。
- 依赖指向内部。Domain 对外部世界一无所知。
- 驱动型 adapter 调用你的应用;被驱动型 adapter 被你的应用调用。
- 按需取用:初创公司用几个 port,企业级追求供应商独立,纯 CRUD 应用几乎不需要。
- 增量引入——每次只处理一个最痛的接缝。永远不需要大规模重写。
本系列的下一篇将在这个基础上,探讨 Ports & Adapters 与 Clean Architecture 以及 Onion Architecture 的关系——三个名字指向同一颗北极星——以及它们在实践中真正的差异所在。