一家只售卖合成商品 paper 的教学商店,订单行单价 50.00、数量 2。现有 Before.quote 给出 100.00,Before.receiptTotal 也给出 100.00。两个结果都对。新增会员九折后,两条路径仍各自做 unitPrice × quantity:报价和收据会同时给出错误的 100.00,而需要的是 90.00。能计算旧订单,不代表容易一致地修改新规则。

本篇先建立可执行的行为基线,只解决折扣规则在两个入口重复的问题。不预建支付接口、工厂和其他模式:目前没有这些变化需求。

先界定行为,再讨论改法

工程位于 examples/design-patterns/,执行 ./mvnw -B -ntp verify。order-core 的 OrderLine 是报价输入,记录 SKU、两位小数且非负的单价、正整数数量和客户类型;这里的金额均约定为同一币种的教学金额,不处理跨币种兑换。BigDecimal 直接构造自十进制字符串;九折先做乘法,再按 HALF_UP 舍入到两位小数。这是本案例的舍入规则,并非所有支付系统的统一规则。

原行为契约对 Before、After 和 Alternative 使用同一组参数化测试:STANDARD 顾客的报价应等于 100.00,且收据与报价一致。新增需求另有断言:MEMBER 顾客的两条路径均应等于 90.00。旧实现的反例不是把验证入口永久改红,而是断言它确实不等于 90.00;如果旧实现悄悄满足新需求,这条断言会失败。数量为零与单价多于两位小数,则在 OrderLine 构造时通过 assertThrows 验证被拒绝。

输入 原契约:三种实现 新契约:旧实现 新契约:修改后与直接替代
paper、50.00 × 2、STANDARD 报价和收据均为 100.00 不适用 保持 100.00
paper、50.00 × 2、MEMBER 原需求未限定折扣 100.00,明确违约 报价和收据均为 90.00

修改位置是问题,不是类的数量

旧版两个调用入口各自乘单价与数量。如果就在两处分别追加会员判断,少改文件,却要保证未来每个新折扣都同时修改两份知识。After.quote 把计算放到一个位置,After.receiptTotal 调用它:

1
2
3
4
5
6
7
8
9
10
11
12
public static BigDecimal quote(OrderLine orderLine) {
BigDecimal total = orderLine.undiscountedTotal();
if (orderLine.customerType() == OrderLine.CustomerType.MEMBER) {
return total.multiply(new BigDecimal("0.90"))
.setScale(2, RoundingMode.HALF_UP);
}
return total;
}

public static BigDecimal receiptTotal(OrderLine orderLine) {
return quote(orderLine);
}

调用关系变为 收据入口 → 报价入口 → OrderLine.undiscountedTotal();报价入口仍可直接调用。合作对象只有订单行,尚无策略对象;不要仅因方法名叫 quote 就把这一改动命名为某个 GoF 模式。

另一个也可运行的选择是 Alternative:在报价与收据各自写直观的会员分支。它和修改后的版本都满足当前断言;如果两种入口未来实际要采用不同规则,保留分支甚至可能更清楚。当前需求却要求两处金额相同,重复规则增加了遗忘一处的风险。当前选择是共享计算,而不是抽出一个只有单一实现的接口。

旧行为保持与新增功能要分别陈述。Fowler 在 Definition Of Refactoring 中用“不改变可观察行为”限定重构;会员九折是新增行为,不能把它和消除原有重复计算混称为一次纯重构。BigDecimal 的 multiply、setScale 语义可对照 Java SE 21 官方 Javadoc;本例并未从它推出通用的财务政策。

运行结果与可见边界

2026-10-01 在 Ubuntu OpenJDK 21.0.12.1、Maven 3.9.9、JUnit Jupiter 5.11.4 下,./mvnw -B -ntp verify 退出码 0;测试报告 Tests run: 5, Failures: 0, Errors: 0, Skipped: 0。完整原始输出、输入及判定保存在仓库的 examples/design-patterns/evidence/00/;这里不以虚构的控制台截图代替运行结果。

这个实验只证明指定输入和输入约束;不证明所有金额、汇率、库存或真实支付行为,更不能从三种写法的运行成功推断性能差异。要决定何时引入策略或对象协作,还需要接下来的变化需求。

练习

  1. 把会员折扣改为 0.85,新增一个 paper、单价 50.00、数量 2 的断言:报价与收据应同时为 85.00。分别改 After 与 Alternative,在共同的 STANDARD 回归测试不变的条件下记录实际修改点;检查旧版是否依然按预期违约。
  2. 对单价 0.05、数量 1 的会员订单,先按“先乘、HALF_UP 到两位”手算,再在测试中断言结果;如果产品改成逐商品行先舍入再汇总,当前输入与计算入口还能表达新规则吗?先说明契约再修改代码。

参考与系列导航