设计模式 10:只读库存为什么还得实现扣减方法
报价页面只需要回答“库存 3 件,能否预留 2 件”,不负责真正扣减。但早期统一的 InventoryPort 同时列出 available、take 和 restock。只读快照 ReadOnlyBefore 为了给页面提供 available,不得不实现后两个方法,运行时统一抛 UnsupportedOperationException。页面的查询本来能工作;问题是它在类型层面承诺了自己做不到的操作。 先看谁会调用协议 Before.canReserve 只调用一次 inventory.available(sku)。三种设计使用合成库存 paper=3 的共同合同:请求 2 返回 true,请求 4、未知 SKU 和请求 0 返回 false。旧实现没有在此查询上出错;反例发生在同一个 InventoryPort 类型对别的调用方也暴露了 take、restock。实验分别调用这两个占位方法,断言它们抛 UnsupportedOperationException,随后库存仍为 3。Java SE 21 UnsupportedOperationException Javadoc...
设计模式 09:继承成功,为什么预留对象仍不能替换
基类 Before 允许库存为 3 时预留 1 件:成功后库存剩 2,已预留量为 1;数量为 0、库存不足和重复预留会被拒绝,拒绝后状态不变。四个子类都能写 extends Before,代码也能编译,但它们分别改变了“允许什么输入”“成功必须保证什么”“状态始终合法”和“状态在多次操作中能否倒退”。这四种问题不能用同一个“类型匹配成功”来证明没事。 让客户端合同,而不是子类名称,决定可替换性 Liskov 与 Wing 的 A Behavioral Notion of Subtyping把类型约定中的方法前后条件、不变量以及跨状态的历史约束放在一起讨论。文中用容器的边界示例区别某个状态合法与两个状态之间保持的性质;不能把历史约束缩减为一次方法返回时的布尔值。Java SE 21 JLS §8.4.8.1说明实例方法在语言层面如何重写,但 @Override 不会验证这种行为合同。 实验把调用放进同一套可失败断言。在独立的新对象上先预留 1,检查库存 2、已预留 1、重复预留被拒且库存不变;再检查数量 0 和请求 4 均拒绝且状态保持。另对已预留对象执行实现所允许的附加动作,再检...
设计模式 08:新增折扣时,究竟不改哪段代码
已有的报价程序对普通订单取原价,对会员打九折。新需求是“大宗订单按八五折”,不是把计算器改造成通用规则引擎。若每加一个折扣都去改同一段条件分支,这段程序就要反复经受回归风险;若需求变成税额与应付额分列,仅在别处加一条折扣策略也解决不了问题。谈“对扩展开放、对修改关闭”之前,要先说清楚保护的是哪种变化。 先给旧流程一个可观察的边界 对合成订单行 paper × 2、单价 10.00 CNY,三种设计的原行为一致:STANDARD 总额 20.00,MEMBER 总额 18.00。Before.quote 只识别会员;调用方提供新增的 BULK 时走默认原价 20.00,测试明确断言这个旧版违约。修补旧版需要改动原有判断。Alternative.quote 则在一个 switch 里补 BULK → 0.85,同样能满足新需求,并不因没有策略接口而被判错。 After.quote 把“某一种折扣对应的倍率”作为 DiscountRule.multiplier(),自己只负责把原价乘倍率并按两位小数舍入: 1234public BigDecimal quote(OrderLine li...
设计模式 07:谁在要求修改报价与报表
第 06 篇把计算、内存保存和展示的传播范围拆开了。本篇不再比较同一个报价文本前缀,而是让定价规则和导出报表产生新的独立变化:合成的定价方要求会员使用优惠券时,在九折后再减 1.00;报表使用方要求 SKU 含逗号或双引号时,导出行正确加引号并转义。两种要求都可能出现在同一份报价单上,却不由同一方定义。 Before.quoteSheet 有两个动作:计算总额,随后把 SKU 与金额拼成一行 CSV。普通客户买两件单价 10.00 的 paper,得到 20.00 与 paper,20.00;会员得 18.00 与 paper,18.00。这是三种实现都运行的同一原行为合同。新增请求里,旧版接受 coupon=true 仍返回会员 18.00,接受 strictCsv=true 仍把 SKU paper,lined 输出成 paper,lined,20.00:第三列看起来像金额,实际 SKU 已被拆开。旧版的错误输出都在测试中明确断言,而不是让验证入口一直失败。 定义职责要先找到提出变化的人 Robert C. Martin 在 The Single Responsibility...
设计模式 06:两次独立改需求,为什么都要碰同一服务
一个报价服务算金额、把金额放进内存 Map,再组装给调用方的文本。最初普通客户买 2 件、单价 10.00,得到 20.00 和 CNY 20.00;会员沿用九折得到 18.00。现在分别收到两张变化卡:A 仅在活动开启时把会员改为八折,B 只把展示文本换成 合计(CNY):金额。两张卡来自不同变化来源,金额保存行为不该因为文案变化而改变。旧版 Before 的计算、内存保存和展示全部挤在同一类里。 变化传播不是类数竞赛 Before.quote 的实际顺序是 line.undiscountedTotal() → 按会员九折计算 → saved.put(id, total) → 拼接 CNY 前缀。它接受 promotion 和 detailed 两个开关,却没有处理新要求;对会员开启活动仍保存 18.00,指定详细展示仍返回 CNY 20.00。测试显式断言这两个旧输出,作为可执行的变化压力,不把基线伪装为已满足需求。 拆开的 After 仍只有一个入口,负责按顺序协作: 123BigDecimal total = pricing.total(line, promotion)...
设计模式 05:重写 add 为什么拦不住批量添加
一个合成购物清单需要记录成功加入的条目数。旧版 Before 继承可扩展的 BaseCart,重写 add 后计数:调用 add("paper") 得到 1;再调用 addAll(List.of("pen", "book")),清单已有 3 项,计数却仍是 1。继承允许重写一个入口,却没有保证其他入口都会走它。这与 04 篇 的两阶段方法选择相关,但这里的失败点不是编译器选错重载,而是实际批量调用链根本没有调用被重写的方法。 沿真实代码找缺口 实验里 BaseCart.add 把一个条目加入内部 ArrayList;BaseCart.addAll 则直接调用内部 items.addAll(incoming),没有经过 this.add。子类只重写了 add: 12345@Overridepublic void add(String item) { super.add(item); insertionCount++;} 因此单条路径调用 Before.add → BaseCart.add →...
设计模式 04:为什么重载没能识别整箱预留
普通预留可以取 1 件;整箱预留要求一次取偶数件。订单和库存仍沿用 03 篇的单线程契约:拒绝无效请求后,两个对象的状态都不能改变。新增规则看起来只需为 Bundle 添一个重载;但调用方把它存为 ReservationChannel 时,同一个整箱对象取 1 件竟然成功了。 两个阶段,不能交换 Java SE 21 JLS §15.12.2.5规定:在可适用的方法里,编译时选择最具体的重载,确定后续分派所用的方法描述符。§15.12.4.4再讨论运行时定位要调用的实例方法;类实例方法的重写规则见 §8.4.8.1。运行时对象能决定所选签名的哪个实现执行,却不会重新挑一个参数类型更窄的重载。 Before 的两条路径都是真实编译并执行过的代码,区别只在调用处的变量声明类型: 123ReservationChannel channel = new ReservationChannel.Bundle();before.reserve(channel, order, inventory, 1); // 调用 reserve(Reservat...
设计模式 03:库存预留失败,订单和库存为什么仍会变化
合成库存有 3 件,订单 o-01 要预留 4 件。旧版 Before.reserve(4) 会抛出“库存不足”,但在抛出异常之前已经扣减库存;调用者收到失败,读回库存却是 -1。再看重复操作:第一次预留 1 件之后库存为 2,第二次虽然报“已预留”,却先又扣掉 1 件。异常类型说“拒绝”,状态却说“成功了一部分”,后续调用便无法信任这份库存。 前两篇分别处理金额的别名与返回集合的所有权。本篇检查对象操作本身:允许什么输入,成功后承诺什么,失败后是否守住此前合法的状态。设计模式 01建立有 ID 的订单;这里用它承载新增的预留状态,库存仍由单独的 Inventory 保管。 把可失败断言写成契约 本实验约定预留前订单尚未预留,库存非负;成功输入为正整数且不超过当前库存。成功后订单的预留量等于输入,库存减少相同数量;已预留当且仅当预留量大于零。输入 0 或 -1、库存 3 却要 4、或已预留后再次预留,都必须拒绝,订单和库存必须保持拒绝前的值。这是本地教学操作的契约,并非数据库或支付系统的事务承诺。 库存为 3 时的请求 旧实现拒绝后的实际状态 修改后 / 简单替代拒绝后的状...
设计模式 02:字段是 private,清单为什么还会被外部改写
教学报价清单规定:只能通过 SkuBook.add 加商品,null 商品不合法。Before 的内部 skus 字段虽然是 private,却把这个 ArrayList 直接从 items() 返回。调用者收到的不是一次查询结果的独立副本,而是能写回原对象的别名。实验先让调用者把清单清空,再从 items() 验证内部也空了;随后绕过 add(null) 的拒绝路径,直接在返回的集合上插入 null,并从书中读到这个非法元素。 问题不在点号有几个,也不在 getter 是否有 private 字段:一个本应通过业务入口维护的状态,获得了第二条未受约束的修改路径。第 01 篇讨论可变金额作 HashMap 键的别名问题,本篇则看返回集合这一条边界。 三种返回方式,三种时间语义 labs/02 把三个实现放在同一个 SkuBook 测试协议下。对于“初始为空、add("paper") 后能读到商品、add(null) 被拒且状态不变”,三者使用相同输入与断言。新需求另加两条约束:外部不能通过读取结果改写内部;已经拿到的读取结果不能随着后续的内部添加变化。 ...
设计模式 01:订单身份和金额值为什么不能混为一谈
把金额 10.00 CNY 作为报价索引的键,保存后又在同一个对象上改成 11.00 CNY。此时“持有同一个对象引用”并不能保证还能从 HashMap 找回报价:Before.MutableAmount 的 hashCode 取决于可改的金额。实验先断言存入后能查到,再改金额,最后断言当前输入和 JDK 下查询结果是 null。这不是 HashMap 必然在所有键修改后都查不到的定律,而是说明键入表后的散列语义不再稳定。 第 00 篇守住了旧报价 50.00 × 2 = 100.00,并把会员新折扣另列断言。这里不改变报价契约:Before.total、After.total 和不建金额类的 Alternative.total 对同一 OrderLine 运行同一组参数化断言,三次均得到 100.00。新需求是金额要作为稳定的值传给多个协作者,同时订单改价不能改变订单的身份。 值相等不是引用相同 教学工程在 order-core 新增了 Money:一个 record,包含十进制金额和币种代码。构造时以 RoundingMode.UNNECESSARY 将金额归一到两位小数:1...










