3.2 领域服务与工厂:对象装不下的逻辑


3.2 领域服务与工厂:对象装不下的逻辑

本节摘要:不是所有逻辑都能体面地住进实体或值对象:跨多个对象的计算放进任何一个对象都会让它越权,复杂的构造过程可能需要重建一组不变量。领域服务(Domain Service)与工厂(Factory)就是这两种"装不下"的正规去处。本节给出逻辑归属的统一判据——逻辑住在它操心不变量的那个家里——并解释为什么领域服务的滥用正是 1.4 贫血模型的老路。

逻辑的三种去处

3.1 结尾留了一个尾巴:促销分摊这种逻辑,横跨订单的每一行,塞进任何一行都别扭。把视野放宽,这是所有团队的日常难题——写业务时每一段逻辑都要面对"放哪"的三选一:实体方法、领域服务、应用服务。三者的边界划错,代码就会滑向"Service 增重、对象失能"的老路。

统一判据一句话:逻辑住在它操心的那个不变量的家里。"金额不能为负"操心的是金额自身,住在 Money;"订单行数量必须为正"操心的是行自身,住在 OrderLine;"一张订单的行合计必须等于总额"操心的是订单整体,住在 Order;"折扣要按行金额比例分摊到每一行"操心的是多行之间的关系,任何一行都无权代言,只能交给领域服务;"开启一个事务、记一条审计日志"操心的根本不是业务不变量而是流程编排,交给应用服务。按这条判据往下走,三种去处各自的特征就清楚了:

去处 状态 操心的对象 接口语言 典型例子
实体/值对象方法 有状态或不可变 自身或内部关系 业务词(cancel、meets) 关单、门槛判断
领域服务 无状态 跨对象的关系规则 业务词(allocate、quote) 分摊、保费试算
应用服务 无状态 用例流程与事务编排 用例词(placeOrder) 下单用例、事务边界

领域服务与应用服务的分界最容易被含糊掉。测试方法:把接口名念给业务专家听,他能听懂且愿意纠正的,是领域服务;他听了摇头说"这是你们技术上的事"的,是应用服务。"分摊规则讲给他听,他会说'对,退款时就是按这个比例退'——它是领域服务;"下单事务里先存订单再发消息"他毫无感觉——应用服务。

领域服务:跨对象关系的正规住址

青柚商城的真实例子:退款要按行退,于是优惠分摊成了刚需——一张订单总额 300、用了满 300 减 50 的券,退掉其中一行 100 的商品,该退用户多少钱?答案要求把 50 块优惠按行金额比例分摊到三行上,退款金额才既不占公司便宜也不占用户便宜。这条规则关心三行的比例关系,任何一行自己都算不出来:

// 领域服务:优惠分摊——无状态,操心的不变量是"分摊后各行优惠之和等于总额" public class PromotionAllocationService { public List<AllocatedLine> allocate(Money totalDiscount, List<OrderLine> lines) { // 按行金额比例分摊 Money lineSum = lines.stream().map(OrderLine::lineTotal) .reduce(Money.ZERO, Money::plus); List<AllocatedLine> result = new ArrayList<>(); Money allocated = Money.ZERO; for (int i = 0; i < lines.size(); i++) { OrderLine line = lines.get(i); if (i == lines.size() - 1) { // 最后一行兜底:吸收舍入差,保证恒等式成立 result.add(new AllocatedLine(line, totalDiscount.minus(allocated))); } else { Money share = totalDiscount.ratioOf(line.lineTotal(), lineSum); allocated = allocated.plus(share); result.add(new AllocatedLine(line, share)); } } verify(totalDiscount, result); // 分摊后复核恒等式,失败即抛异常 return result; } private void verify(Money total, List<AllocatedLine> result) { Money sum = result.stream().map(AllocatedLine::discount) .reduce(Money.ZERO, Money::plus); if (!sum.equals(total)) throw new IllegalStateException("分摊结果不守恒"); } }

三个细节体现领域服务的纪律。无状态:类里没有一个实例字段,两次调用互不影响——有状态的"服务"几乎总在掩饰对象失职。守恒量自己验:"各行分摊之和等于总额"这条不变量不能指望调用方,服务自己 verify,舍入误差用"最后一行兜底"消化。接口说业务话:方法签名里没有一行 SQL、没有一个传输对象,业务专家逐词都能跟上。

对照一下滥用形态。同一个分摊逻辑,贫血写法会把它写进 OrderService.allocateDiscount(orderId, discount):服务里先查库、再取行、再算比例、再存库——业务判断与持久化流程缠在一起,想给分摊规则写个单元测试得先起数据库。拆出领域服务后,测试就是一个纯函数调用:给三行金额、一个总额,断言输出。领域服务的价值不在"多一层",而在把关系规则从流程泥潭里捞出、还给测试自由。

工厂:不变量的重建现场

构造逻辑什么时候值得一个独立的工厂?判据还是不变量:当"造出一个合法对象"本身需要业务判断时。三个参数的 new Money(100) 不需要工厂——校验写在构造器里就够了。但"从购物车结算生成订单"需要:购物车快照要转成订单行、券要核验状态、应付金额要重算、订单号要分配——造出一个合法订单是一段完整的业务过程:

// 工厂:从购物车快照重建订单的不变量 public class OrderFactory { private final PromotionAllocationService allocation; // 复用分摊服务 private final OrderIdGenerator idGen; public Order createFrom(CartSnapshot cart, CouponView coupon) { if (cart.isEmpty()) throw new IllegalArgumentException("空车不能结算"); List<OrderLine> lines = cart.items().stream() .map(i -> new OrderLine(idGen.nextLineId(), i.skuId(), i.qty(), i.price())) .toList(); Money subtotal = lines.stream().map(OrderLine::lineTotal) .reduce(Money.ZERO, Money::plus); // 券在构造时刻必须重新核验:快照里的"可用"不代表此刻可用 if (coupon != null && !coupon.usableNow(subtotal)) { throw new CouponNotApplicableException(coupon.id()); } Money payable = subtotal.minus(coupon == null ? Money.ZERO : coupon.amount()); Order order = new Order(idGen.nextOrderId(), lines, payable); if (coupon != null) { order.attachCoupon(coupon.id(), allocation.allocate(coupon.amount(), lines)); // 分摊结果随构造固化 } return order; // 出厂即合法:金额恒等、券已核验、行已分配 } }

工厂的价值主张一句话:出厂即合法。对象在构造完成的那一刻就满足全部不变量,后面所有代码都不必再防御"半成品对象"。反模式是让 new Order() 出一个空壳,再由调用方一个个 set——每个 set 环节都是不变量的裸奔窗口,"new 完没 set 券就 save 了"这类线上事故都从这里来。

还有一种工厂的别名场景叫"重建":从数据库快照还原聚合(3.3 的仓库会用到)。重建与新建的差别在于新建要产出业务事件、重建不该——把两者写在一个构造器里,就会出现"从库里读一批订单,凭空多发一批订单已创建事件"的事故。分开两个工厂方法,是青柚商城踩过一次坑后立下的规矩。

带走的判断

  • 逻辑归属判据只有一条:住在它操心不变量的家里;拿这条去审任何一段"该放哪"的争论,比争论代码风格有效。
  • 领域服务无状态、接口说业务话、自己验自己的恒等式;做不到这三条,多半它其实是应用服务或该进实体。
  • 工厂的存在理由是"出厂即合法",新建与重建要分开——两者的事件语义完全不同。
  • 领域服务每多一个,就多问一次:这逻辑真没有家吗?服务层增重是贫血模型复发的第一信号。

对象构造好、逻辑归了位,下一站处理两件"身后事":对象存到哪、以及对象状态变了之后怎么通知别人——3.3 仓库与领域事件。


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