1.4 对照组实验:事务脚本、贫血模型与充血模型


1.4 对照组实验:事务脚本、贫血模型与充血模型

本节摘要:与其争论风格优劣,不如做实验。本节把青柚商城"计算订单应付金额"这一段业务,分别用事务脚本、贫血模型、充血模型三种写法实现,然后模拟三轮真实的规则变更,逐轮统计修改波及面。结论会显示出:行为与数据分离的程度,直接决定规则变更时的爆炸半径。

同一需求的三种写法

实验的需求只有一句话:订单要算出应付金额,规则包括商品小计、满 300 减 50 的满减、以及每单限用一张的优惠券。三种写法都正确实现了这条规则——这一点很关键,风格之争里双方往往都能把需求做对,分出高下的是变化到来的时候

写法一:事务脚本。业务逻辑写成过程函数,直接操作数据行,数据和行为彻底分开:

// 事务脚本:一个方法管到底 public class OrderService { public BigDecimal calcPayable(OrderRow order, List<ItemRow> items, CouponRow coupon, PromoRule promo) { BigDecimal subtotal = BigDecimal.ZERO; for (ItemRow item : items) { subtotal = subtotal.add(item.getPrice() .multiply(BigDecimal.valueOf(item.getQty()))); } BigDecimal payable = subtotal; // 满减规则 if (promo != null && subtotal.compareTo(promo.getThreshold()) >= 0) { payable = payable.subtract(promo.getDiscount()); } // 券规则:每单限用一张,且不得与满减叠加 if (coupon != null && subtotal.compareTo(coupon.getMinSpend()) >= 0) { payable = payable.subtract(coupon.getAmount()); } return payable.max(BigDecimal.ZERO); } }

写法二:贫血模型。数据挪进"领域对象",但对象只有字段和 getter,逻辑原封不动搬进服务层——这是最多的团队实际在用的形态,看起来像面向对象,其实是披着类外衣的事务脚本:

// 贫血模型:Order 是纯数据袋,规则仍在 Service public class Order { // 只有数据 private BigDecimal subtotal; private BigDecimal couponAmount; private BigDecimal payable; // getter / setter 全部省略 } public class OrderDomainService { public void calcPayable(Order order, Coupon coupon, PromoRule promo) { BigDecimal payable = order.getSubtotal(); if (promo != null && order.getSubtotal().compareTo(promo.getThreshold()) >= 0) { payable = payable.subtract(promo.getDiscount()); } if (coupon != null && order.getSubtotal().compareTo(coupon.getMinSpend()) >= 0) { payable = payable.subtract(coupon.getAmount()); } order.setPayable(payable.max(BigDecimal.ZERO)); } }

写法三:充血模型。规则住进对象,订单会"自己算账",外部只下达指令不询问细节:

// 充血模型:规则内聚在领域对象里 public class Order { private final List<OrderLine> lines; private Coupon coupon; // 值对象,见第 3 章 public BigDecimal subtotal() { return lines.stream() .map(OrderLine::lineTotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } public BigDecimal payable(PromoRule promo) { BigDecimal amount = subtotal(); amount = amount.subtract(discountOf(promo)); amount = amount.subtract(couponDiscount()); return amount.max(BigDecimal.ZERO); } private BigDecimal discountOf(PromoRule promo) { // 满减与券互斥的规则,只在这里出现一次 if (coupon != null) return BigDecimal.ZERO; if (promo != null && subtotal().compareTo(promo.threshold()) >= 0) { return promo.discount(); } return BigDecimal.ZERO; } private BigDecimal couponDiscount() { if (coupon == null || subtotal().compareTo(coupon.minSpend()) < 0) { return BigDecimal.ZERO; } return coupon.amount(); } }

三轮规则变更

现在让业务变化进场,每一轮都对三种写法做同一件事:找到所有需要修改的位置,统计修改点的分布。

第一轮:满减门槛从 300 调到 200。事务脚本:交易服务一处、营销系统核销一处、财务对账一处——三处副本三处改。贫血模型:同样的三处,只是搬进了三个 Service。充血模型:PromoRule 是从营销上下文传入的参数,交易侧零修改,改的只是营销系统里规则的配置值。第一轮的差距还不是最大——门槛毕竟是个配置值。

第二轮:券与满减从"互斥"改为"可叠加,但叠加后总额不超过小计的 30%"。这条规则涉及两种优惠的关系,事务脚本和贫血模型里它被写在每个调用方各自的 if 里:交易系统改一遍,财务对账改一遍,客服工单的"优惠明细"展示再改一遍,漏掉任何一处就是口径分裂。充血模型里,关系规则只住在 Order 内部——因为"一笔订单上同时有券和满减"这个事实,只有订单自己最清楚;其他系统通过接口拿到的应付金额天然正确。

第三轮:新增"会员日双倍积分",积分基数依赖实付金额。事务脚本:积分系统自己再实现一遍应付金额计算(因为历史原因它没调交易接口),规则第三次复制。充血模型:积分系统订阅"订单已支付"领域事件,事件里带着金额与优惠构成明细——它消费事实,不复演计算。

三轮下来统计:

变更轮次 事务脚本修改点 贫血模型修改点 充血模型修改点
门槛 300 调 200 3 处(3 个系统) 3 处(3 个 Service) 1 处(规则配置)
互斥改叠加限 30% 3 处,漏 1 处酿成资损 3 处,靠联调兜住 1 处(Order 内部)
新增会员日积分 新增第 4 份副本 新增第 4 份副本 0 处(消费事件)

实验说明了什么

差异的根源不在代码量——充血模型的代码行数反而略多。根源在于规则的存放位置:事务脚本和贫血模型把规则副本散落在每个使用它的人手里,副本数量等于使用者数量;充血模型让规则只有一个家,使用者按需索取结果。爆炸半径的差距由此而来。

顺带澄清一个常见误解:贫血模型不等于"错的架构"。它的真正名字应该是失焦——该由领域对象承担的判断被抽走了,剩下的对象退化成数据袋。什么时候这种退化可以容忍?规则极薄、以查询为主的场景(第 6 章的查询侧就是这么干的);什么时候不可容忍?规则厚、变更多、多系统共用口径的场景。这个判断标准和 1.3 的评估清单一脉相承。

青柚商城的团队做完这组实验后没有立刻全量重构——他们在下一章先去解决一个更根本的问题:连"订单"这个词三个系统都没谈拢,写法再先进也白搭。这为第 2 章的战略设计铺垫了足够的动机。

三条实验结论

  • 副本数量等于爆炸半径:规则每多一份副本,每次变更就多一处出错机会——三种写法的所有差距都从这里派生。
  • 关系规则应该住在关系所在的对象里:券与满减的关系属于订单,积分基数属于支付,各回各家。
  • 写法选择要与 1.3 的评估联动:复杂域用充血模型守住规则唯一归属,薄规则的查询侧大方用事务脚本,不必洁癖。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U