设计模式 11:支付端口应该由谁定义
合成订单 o-11 应付 10.00。最初的 before.Checkout 在业务方法中直接调用 FakeCardSdk.chargeCard:支付成功就返回 Receipt,拒付则抛异常;金额为 0 也要在碰到支付细节前拒绝。后来新增“不联网、只记本地账本”的支付方式,如果每次改供应商都在 Checkout 里加条件,它就不得不认识每一种外部 SDK。
被倒置的是源码依赖,不是运行时调用方向
After 将 PaymentPort.charge(orderId, amount) 放在 domain 包,domain.Checkout 只依赖这份业务侧定义的协议;SandboxCardAdapter 与 OfflineLedgerAdapter 均依赖它。卡片适配器内部再调用 FakeCardSdk,账本适配器只记录内存金额。测试在组装处手工选择:
1 | |
两种方式仍然执行同一合同:金额 10.00 支付成功后返回 ID 和金额一致的收据,模拟拒付时抛 IllegalStateException 且不记账,非正金额抛 IllegalArgumentException 且不调用支付路径。旧实现、两个新适配器和单类 Alternative 共四个变体都跑这些可失败断言;没有把“换个构造器参数”当成支付业务已经正确的证据。
flowchart LR
Old[before.Checkout] --> Sdk[external.FakeCardSdk]
Composition[手工组装] --> Domain[domain.Checkout]
Domain --> Port[domain.PaymentPort]
Card[adapters.SandboxCardAdapter] --> Port
Card --> Sdk
Ledger[adapters.OfflineLedgerAdapter] --> Port
实际在编译输出上运行 jdeps -verbose:package -filter:none labs/11/target/classes,观察到 blog.examples.lab11.before → blog.examples.lab11.external,以及 blog.examples.lab11.adapters → blog.examples.lab11.domain、adapters → external;没有 domain → adapters 或 domain → external。这是包级字节码依赖的实测,不是仅从构造器签名推测。运行时调用则仍然从 Checkout 经端口分派到具体适配器;“依赖倒置”不要求运行时调用也反过来。
| 设计 | 业务源码知道什么 | 新增另一支付细节时的修改点 |
|---|---|---|
before.Checkout |
直接导入 FakeCardSdk |
可能要改业务 Checkout 内的细节选择 |
domain.Checkout + 两个适配器 |
只知道业务侧 PaymentPort |
新增适配器并在组装处选择,既有 Checkout 不必改 |
Alternative 单类本地账本 |
没有通用端口 | 若永远只有本地账本,直接分支更便宜;换渠道时要改此类 |
Robert C. Martin 的 The Dependency Inversion Principle讨论高层策略依赖抽象、低层细节同样依赖抽象。本例的抽象归属于业务包,而不是放进模拟 SDK。Martin Fowler 的 Inversion of Control Containers and the Dependency Injection Pattern区分装配与使用,也对比注入和 Service Locator;这里用 new 手工装配就够了,DIP 是源码依赖方向,DI 是把实现交给对象的一种方式,IoC 并不等于必须引入容器。拆分是否值得,取决于支付方式变化和测试替身的实际需要,而不是接口看起来更“企业级”。
本章全部支付行为是合成模拟:卡片 SDK 只是内存 Map,账本也只是内存 Map,以 declined- 前缀模拟拒付。不测试真实网关、重试幂等、掉电恢复、数据库事务、退款或消息投递;一次方法返回的收据不代表真正到账。累计 order-core 没有因此引入第三方支付依赖。
验证与练习
2026-10-02 在 Ubuntu OpenJDK 21.0.12.1、Maven Wrapper 3.9.9 下运行 ./mvnw -B -ntp verify,退出码 0;11 的 13 次测试及 00–10 的 80 次回归通过。还对编译输出运行 jdeps -verbose:package -filter:none,退出码 0。合成输入、原始日志、包方向判定见 examples/design-patterns/evidence/11/。
- 增加第三个“总是拒付”的适配器。先让它跑同一合同中的成功与拒付用例:它会在哪个用例失败?为明确的测试目的再定义失败合同;记录是否需要修改
domain.Checkout和哪一处组装代码,不能靠接口存在就声称扩展成功。 - 新需求要求退款后收据状态从“已付”变为“已退”。先写“退款成功/重复退款/SDK 抛异常”各自的可失败断言,再决定是否必须扩展业务侧端口及状态模型;对这种业务协议本身变化,DIP 不承诺既有 Checkout 一行不改。
参考与系列导航
- Robert C. Martin:The Dependency Inversion Principle:高层规则、抽象与实现细节的依赖方向。
- Martin Fowler:Inversion of Control Containers and the Dependency Injection Pattern:注入、组装和使用的分离。
- 10 ISP 接口隔离 ← 11 DIP 依赖倒置(本篇) → 12 最少知识与 Tell, Don’t Ask。






