合成库存有 3 件,订单 o-01 要预留 4 件。旧版 Before.reserve(4) 会抛出“库存不足”,但在抛出异常之前已经扣减库存;调用者收到失败,读回库存却是 -1。再看重复操作:第一次预留 1 件之后库存为 2,第二次虽然报“已预留”,却先又扣掉 1 件。异常类型说“拒绝”,状态却说“成功了一部分”,后续调用便无法信任这份库存。

前两篇分别处理金额的别名与返回集合的所有权。本篇检查对象操作本身:允许什么输入,成功后承诺什么,失败后是否守住此前合法的状态。设计模式 01建立有 ID 的订单;这里用它承载新增的预留状态,库存仍由单独的 Inventory 保管。

把可失败断言写成契约

本实验约定预留前订单尚未预留,库存非负;成功输入为正整数且不超过当前库存。成功后订单的预留量等于输入,库存减少相同数量;已预留当且仅当预留量大于零。输入 0 或 -1、库存 3 却要 4、或已预留后再次预留,都必须拒绝,订单和库存必须保持拒绝前的值。这是本地教学操作的契约,并非数据库或支付系统的事务承诺。

库存为 3 时的请求 旧实现拒绝后的实际状态 修改后 / 简单替代拒绝后的状态
reserve(0) 订单被标记已预留,量却为 0 订单仍未预留;库存仍为 3
reserve(4) 库存变为 -1 订单仍未预留;库存仍为 3
已预留 1 后再 reserve(1) 第二次拒绝,但库存由 2 再减为 1 订单仍预留 1;库存仍为 2

对三个实现,原来的合法请求 reserve(2) 使用同一组输入和断言:剩余库存 1、订单预留量 2。新需求单列断言:修正版和直接替代均保持拒绝前状态;旧版的三个错误状态由负例测试明确预期,所以实验入口整体可以保持绿灯。代码测试适配器只为统一契约服务,没有预建额外的业务接口。

先检查,再修改

累计工程中的 Order 在第 01 篇已有稳定 ID 和金额值;本篇才加入 reservedQuantity 与 reserve。调用顺序对应实际 Java 方法:

1
2
3
4
5
6
7
8
9
10
11
public void reserve(int quantity, Inventory inventory) {
if (isReserved()) {
throw new IllegalStateException("order already reserved");
}
Objects.requireNonNull(inventory, "inventory");
if (quantity <= 0) {
throw new IllegalArgumentException("non-positive quantity");
}
inventory.take(quantity);
reservedQuantity = quantity;
}

Inventory.take 在扣减前检查数量和当前库存。订单只有在该调用成功返回后才记录预留量;重复预留先被 Order 拒绝,库存根本不会接到第二次扣减。Inventory 自身也检查数量,是因为它还有自己的公开调用入口,不能依赖所有调用者总是先经过 Order。这不是为了满足某个模式的参与者数量,而是两份状态各守住自己的边界。

sequenceDiagram
    participant Caller as 调用方
    participant Order as Order
    participant Stock as Inventory
    Caller->>Order: reserve(2, stock)
    Order->>Order: 检查未预留、数量为正
    Order->>Stock: take(2)
    Stock->>Stock: 检查库存足够,再扣减
    Stock-->>Order: 成功
    Order->>Order: reservedQuantity = 2
    Order-->>Caller: 返回成功

一个更简单且同样通过合同的版本是 Alternative:库存和预留量都在一个对象里,三个条件分支检查完再改变整数。若只处理单订单、单库存计数,它的控制流更短;在既有的有身份订单与共享库存需要分开演化时,Order 和 Inventory 的协作才带来清晰的状态归属。两种设计都不会因为类数不同而自动获得事务或并发安全。

Eiffel 的 Design by Contract 用前置条件、后置条件和不变量表述组件约定;这里的库存数值及失败后保持不变是本实验写出的具体条件,不是从该资料推导出的支付或持久化规范。Java 官方 IllegalStateException 描述方法在对象不适当状态下被调用;本篇选择它标识库存不足和重复预留,异常分类本身仍是教学 API 的取舍。

验证边界和练习

2026-10-02 在 Ubuntu OpenJDK 21.0.12.1、Maven 3.9.9 下运行 ./mvnw -B -ntp verify,退出码 0;03 的 7 次测试与 00–02 共 18 次回归均无失败、错误或跳过。输入、命令和原始输出在 examples/design-patterns/evidence/03/。这仅验证单进程顺序调用:并发请求可能同时看到“未预留”,跨进程故障也不能靠两个普通 Java 方法保证原子性。本实验没有做这些测试,不据此声称并发或事务正确。

  1. 新需求允许已预留订单取消并释放库存一次。先写“取消成功后库存回到 3、预留量归零、再次取消被拒且状态不变”的契约,再决定哪个对象负责增加库存;同时跑原先三种失败分支回归。不要把异常被抛出当成状态没改的证据。
  2. 如果 Inventory.take 在扣减后可能因外部系统故障抛异常,当前写法能保证库存与订单一致吗?列出最小反例,并说明此时单线程局部断言为何不足以证明跨服务原子性;不用虚构生产事故。

参考与系列导航