合成订单 o-11 应付 10.00。最初的 before.Checkout 在业务方法中直接调用 FakeCardSdk.chargeCard:支付成功就返回 Receipt,拒付则抛异常;金额为 0 也要在碰到支付细节前拒绝。后来新增“不联网、只记本地账本”的支付方式,如果每次改供应商都在 Checkout 里加条件,它就不得不认识每一种外部 SDK。

被倒置的是源码依赖,不是运行时调用方向

After 将 PaymentPort.charge(orderId, amount) 放在 domain 包,domain.Checkout 只依赖这份业务侧定义的协议;SandboxCardAdapter 与 OfflineLedgerAdapter 均依赖它。卡片适配器内部再调用 FakeCardSdk,账本适配器只记录内存金额。测试在组装处手工选择:

1
2
Checkout byCard = new Checkout(new SandboxCardAdapter(sdk));
Checkout byLedger = new Checkout(ledger);

两种方式仍然执行同一合同:金额 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/。

  1. 增加第三个“总是拒付”的适配器。先让它跑同一合同中的成功与拒付用例:它会在哪个用例失败?为明确的测试目的再定义失败合同;记录是否需要修改 domain.Checkout 和哪一处组装代码,不能靠接口存在就声称扩展成功。
  2. 新需求要求退款后收据状态从“已付”变为“已退”。先写“退款成功/重复退款/SDK 抛异常”各自的可失败断言,再决定是否必须扩展业务侧端口及状态模型;对这种业务协议本身变化,DIP 不承诺既有 Checkout 一行不改。

参考与系列导航