订单界面要预留两件 paper。老调用者先找到订单里的预留记录,再找到货架,最后取出库存 Map;它自己比较剩余数,并写回扣减结果。今天的合同是:初始库存 3,预留 2 后剩 1;数量为零或库存不足要拒绝,并且拒绝后数量不变。明天的需求是“单次最多预留 2 件”。这条规则究竟该由谁保证?

知道货架结构的调用者

旧版 Before.reserve 中的关键两步来自可执行实验:

1
2
3
Map<String, Integer> stock = order.reservation().shelf().stock();
int remaining = stock.getOrDefault(sku, 0);
stock.put(sku, remaining - quantity);

实际代码还在写回前检查了非正数量和库存不足。问题不是有四个点号,而是调用者知道 Order → Reservation → Shelf → Map 的路径,且任何拿到 order() 的代码都能越过检查直接修改库存。实验直接执行 client.order().reservation().shelf().stock().put("paper", -5);后续查询读到 -5。新增上限规则时,旧调用者允许预留 3 件,余额从 4 变成 1;这是受测的旧行为,不是本章新增合同通过。

把预留命令交给维护状态的一方

After 的 Order 保存私有库存副本,预留入口集中检查 1 <= quantity <= 2、剩余数及扣减。外部只发出 reserve(sku, quantity);调用者拿到的是 Availability(sku, remaining) 值投影,不是可写 Map 或货架。构造后的调用方修改原始输入 Map,也不会把余额变成 99。

1
2
3
4
5
6
7
8
if (quantity <= 0 || quantity > 2) {
throw new IllegalArgumentException("quantity must be between 1 and 2");
}
int remaining = stock.getOrDefault(sku, 0);
if (remaining < quantity) {
throw new IllegalStateException("insufficient stock");
}
stock.put(sku, remaining - quantity);

这段逻辑在 After.Order.reserve 内;其余业务端不必知道货架的嵌套路径。另一种 Alternative 不建订单内层对象,直接在单个库存类上完成同样的校验、扣减与查询。如果目前只有一个小范围调用者、也没有复杂的订单协作,单类实现更便宜;不必为消灭每一个点号再造一串 getShelfStockRemaining() 转发方法。

flowchart LR
    Old[Before 调用者] --> Record[Reservation]
    Record --> Shelf[货架]
    Shelf --> Mutable[可写库存 Map]
    Caller[After 调用者] --> Command[Order.reserve]
    Command --> Private[私有库存]
    Caller --> View[Availability 值投影]
    Simple[Alternative 单类] --> Own[本地库存与校验]
设计 改动“单次最多 2 件”的位置 内部结构与查询的边界
Before 依赖嵌套路径的调用者必须逐一补检查;本实验旧版仍允许 3 件 order() 暴露可写 Map,可从外面直接写入 -5
After Order.reserve 内检查,拒绝时不改变状态 availability(sku) 返回独立的 Availability 值
Alternative 单类 reserve 内检查,拒绝时不改变状态 同样返回值投影;若职责始终很小,不必引入内层订单对象

这里的修改范围是本地代码与测试对比,不是已经证明任何真实项目能自动完成重构。After 仍有一层对订单的用例入口转发,这是一个明确的边界;若没有这个边界的使用者,就选更直接的 Alternative。没有做并发预留或持久化测试,不能把单线程 Map 的检查与写入声称为数据库库存的原子操作。

Northeastern Demeter 团队的 Law of Demeter 通用表述强调对其他单元的知识应有限,并区分类形式与对象形式;对象形式列出方法可以与参数、自身、直接组成部分及方法中创建的对象交互。这不是“所有包含两个点号的语句都违法”的语法检查。Martin Fowler 在 Tell, Don’t Ask 中也指出,行为与数据就近放置有用,却不该因此清除所有查询;对外提供恰当的信息变换或只读投影可能更简单。因此本例把预留决策放回维护状态的对象,仍保留 availability 给展示方读数;避免把视图决定放进库存模型。

验证与练习

在 Ubuntu OpenJDK 21.0.12.1、Maven Wrapper 3.9.9、JUnit Jupiter 5.11.4 下执行 ./mvnw -B -ntp verify,退出码 0。本篇 7 项测试(共同合同 3、反例与新增需求 4)和 00–11 的 93 项回归均通过。完整命令、环境及压缩原始输出在 examples/design-patterns/evidence/12/;此处没有把 Before 的旧上限行为说成新增合同通过。

  1. 增加“每次预留后返回剩余数量”的需求。先让三个变体执行相同的成功、库存不足和零数量断言;再设计返回值,检查失败时是否误报旧库存或意外扣减。说明何时 Availability 值投影足够,何时必须由对象执行命令。
  2. 假设要为展示方显示商品分区,却不允许展示方扣库存。尝试在 Before 的路径上增加分区层,记录哪些调用者必须修改;为 After 与 Alternative 设计一个稳定的只读投影和失败测试,比较多一个查询与一连串转发方法的代价。

参考与系列导航