设计模式 20:唯一实例属于哪个范围
两个不相关的订单测试都要读货币配置。如果把可写的调用次数计数器塞进进程内的“唯一配置”,第二个调用者会读到第一个调用者留下的值;代码里看不见依赖注入,也难以隔离测试。需求究竟是“在一个明确范围内共用只读配置”,还是“一切场景全局共享可写状态”? 用身份断言限定结论 Before.INSTANCE 存可写 calls。原有反例在一个测试方法内观察计数留存;TestStatePollutionTest 进一步用明确的 JUnit 顺序执行两个测试方法:第一个记录基线并递增,第二个断言读到了前一个留下的值。这组有意依赖顺序的测试用于揭示污染,不作为普通业务测试的组织方式。显式传入两份配置值时不存在这条共享身份路径。 After.instance() 改用静态 Holder,实例保存不可变 CNY 字符串。20 次普通任务验证相同引用与币种;冷启动验证另起一个 JVM,避免前面的测试已经初始化实例。八个任务先在 CyclicBarrier 会合,再同时调用 instance(),结果为构造次数从 0 到 1,八个引用一致。没有使用 sleep 猜测线程交错。 12Scenario.Aft...
设计模式 19:复制订单时共享了什么
复制一个报价草稿后,业务员把副本中第一项数量从 2 改成 9;原草稿是否也变成 9?如果只是调用 clone(),表层对象虽不同,里面的清单和可写商品项仍可能共享。复制的合同必须回答“哪个状态可共享”,而不是只检查 original != copied。 表层复制不等于隔离 Before.copy() 使用 Java Object.clone(),测试断言克隆后顶层身份不同、首个 Item 却是同一个引用;修改副本里的 quantity,原本的数量也变成 9。这是本例的反例,不是对所有 clone 实现的断言。After.copy() 用显式拷贝构造逐个新建 Item,还对构造时调用方传入的列表做同样的复制:既隔离副本,也隔离原始输入。负数量抛异常,失败前不改已有副本状态。 123Scenario.After original = new Scenario.After(List.of(new Scenario.Item(2)));Scenario.After copied = original.copy();copied.changeQuantity(0, 9); Alterna...
设计模式 18:构造请求何时检查不变量
普通订单只要商品清单;礼品订单还要收件人。调用方在可写请求上先勾选 gift=true,忘记填写收件人后仍把它交给下游。收件人缺失究竟应该在数据库报错时才发现,还是在“构造完成”的边界被拒绝? 分步收集,最后一次检查 Before 公共字段可以任意顺序被修改;测试特意构造 gift=true, recipient=null 的错误对象。After 用 giftFor 收集可选项,build() 把参数交给不可变的 OrderRequest,后者拒绝空清单、空商品以及礼品收件人为空或空白。giftFor(null) 不能隐式变成普通订单,因此单独保存“已请求礼品”标记;List.copyOf 还使建成后的商品清单与调用方后续改动隔离。 123OrderRequest request = new Scenario.After(List.of("paper")) .giftFor("Ada") .build(); build() 才返回有效请求,Builder 的分步接口本身并不能证明所有字段合法;验证最终由 Orde...
设计模式 17:两种产品如何成套
开单入口要同时给出支付信息与收据模板。国内订单的两份信息写 CNY,出口订单写 USD。旧版让调用方分别传入支付币种和收据币种;USD 支付配 CNY 收据也能构造成功。问题不是某个产品不会创建,而是一对产品能被错误地混搭。 选择一族,而不是逐个选 labs/17 的反例断言 Before.assemble("USD", "CNY").matches() 为假。After 定义有 payment() 与 receipt() 两个方法的 Family;两个枚举实例 DOMESTIC、EXPORT 各自只保存一个币种,两种产品都由它生成。选定一个枚举实例之后,不能在这个 assemble() 调用里独立传入两种币种。国内旧合同的整个 Bundle 与旧版一致;出口产品则断言两者都是 USD。 123Family products = Scenario.After.EXPORT;Payment payment = products.payment();Receipt receipt = products.receipt(); 这是 Abstrac...
设计模式 16:创建决策何时交给子类
原先所有开单请求都生成纸质收据 paper:200。新增电子收据后,金额必须大于零的校验不能因为新增渠道被绕过。把渠道字符串放进同一个创建方法是一种修复,但若不同开单流程分别负责自己的产品,谁能扩展创建决策而不改共同流程? 不先造工厂,先保住旧约束 labs/16 的 Before.issue 校验正金额后直接生成纸质文本。这没有缺陷,直到电子渠道出现。After 的基类用 final issue 固定调用顺序:检查金额 → 调用 createReceipt → 取得文本。Paper 与 Electronic 子类仅决定产品;测试确认新 email:200、旧 paper:200,并断言两种子类对 0 都抛异常。它们是教学中的 Creator 和 ConcreteCreator;产品是返回 text() 的 Receipt。本地字符串收据不表示真实打印或发邮件。 123456public final String issue(int cents) { requirePositive(cents); return createReceipt(cents).te...
设计模式 15:阅读模式先看变化
小额收据原先只显示在屏幕或邮件里,现在审计系统需要 audit/200 的格式。旧方法用渠道名分支,对 audit 抛错。把分支拆成三个类就能叫“策略模式”吗?仅看类图答不了这个问题:先问是谁选择格式、什么行为保持不变、下一次变化要修改哪里。 先补缺失的行为 Before.receipt(200, "audit") 的断言是抛 IllegalArgumentException,刻意保留旧功能缺口。最小修复可以在原 switch 中加一支:Alternative 这样做,还加上负金额拒绝,并保留未知渠道报错。如果渠道是封闭的小集合,需求只来自一个调用位置,这就是合理的实现;没有理由为展示 GoF 模式专门建类层次。 若渠道格式来自调用者的配置,After 接收一个 IntFunction<String>: 123After screen = new After(cents -> "paid " + cents);After audit = new After(cents -> "audit/" + ...
设计模式 14:包依赖为什么成环
结账负责合计,运费负责免邮条件。原本结账调用运费包;后来运费为了复用“满 100 元”判断,反过来调用结账包。即使运行时没有无限递归,pricing → shipping → pricing 也使两个包的变更和编译方向缠在一起。下一次物流部门把免邮线调整到 80 元,哪一侧应该决定规则? 先分开运行时调用与包依赖 labs/14 的 Before 在结账端调用 Shipping.fee,运费端调用 Checkout.qualifiesForFreeShipping。它能算对 0、9_999、10_000 分的旧结果,但包层有环;不能仅从单次请求结果推导依赖图健康。最小修复让 Shipping 自己持有运费规则,让 Checkout 只依赖稳定的 ShippingRule,组装点传入具体运费实现。 123ShippingRule shipping = new Shipping();Checkout checkout = new Checkout(shipping);int total = checkout.total(9_999); 实际包边由 jdeps -verbose:pac...
设计模式 13:重复与抽象哪个更贵
会员满 100 元送礼,订单满 100 元包邮。这两个判断看起来相同,于是有人把门槛抽到 eligible(int cents)。现在会员部门把送礼门槛改成 150 元,物流部门维持 100 元。只改共享常量会同时改变包邮;保留旧常量又会错误地送礼。这里的相似代码并不代表同一条业务知识。 先让独立变化暴露出来 Before 的两种优惠都走同一个 eligible;测试特意断言它在 120 元时仍然送礼,用来记录旧实现的错误,而不是把错误算成新需求通过。最小修复 After 并没有引入策略接口:保留两处比较,礼品条件改为 >= 15_000 分,运费条件保持 >= 10_000 分。金额用分表示以免例子引入浮点舍入问题,这不是通用货币模型。 1234567public boolean loyaltyGift(int cents) { return cents >= 15_000;}public boolean freeShipping(int cents) { return cents >= 10_000;}...
设计模式 12:谁该决定库存预留
订单界面要预留两件 paper。老调用者先找到订单里的预留记录,再找到货架,最后取出库存 Map;它自己比较剩余数,并写回扣减结果。今天的合同是:初始库存 3,预留 2 后剩 1;数量为零或库存不足要拒绝,并且拒绝后数量不变。明天的需求是“单次最多预留 2 件”。这条规则究竟该由谁保证? 知道货架结构的调用者 旧版 Before.reserve 中的关键两步来自可执行实验: 123Map<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().s...
设计模式 11:支付端口应该由谁定义
合成订单 o-11 应付 10.00。最初的 before.Checkout 在业务方法中直接调用 FakeCardSdk.chargeCard:支付成功就返回 Receipt,拒付则抛异常;金额为 0 也要在碰到支付细节前拒绝。后来新增“不联网、只记本地账本”的支付方式,如果每次改供应商都在 Checkout 里加条件,它就不得不认识每一种外部 SDK。 被倒置的是源码依赖,不是运行时调用方向 After 将 PaymentPort.charge(orderId, amount) 放在 domain 包,domain.Checkout 只依赖这份业务侧定义的协议;SandboxCardAdapter 与 OfflineLedgerAdapter 均依赖它。卡片适配器内部再调用 FakeCardSdk,账本适配器只记录内存金额。测试在组装处手工选择: 12Checkout byCard = new Checkout(new SandboxCardAdapter(sdk));Checkout byLedger = new Checkout(ledger); 两种方式仍然执行同一合同...










