公司动态
可验证领域模型:用测试与扩展机制打开业务能力上限
很多团队做领域模型做着做着就做成了“带 getter/setter 的实体壳”。建模阶段轰轰烈烈代码落地后业务规则却散落在 Service 层每加一个需求都要小心翼翼地在十几个方法里找答案。问题通常不是领域模型这个方向错了而是大家把建模当成了画图把验证当成了可选项。这篇文章想讲清楚两个判断。第一可验证是领域模型的生命线没有测试和约束保护的模型越扩展越危险。第二能力扩展无上限不是指模型能无限膨胀而是指通过稳定的领域内核加开放的扩展机制让系统在持续增加业务能力的同时不需要反复推翻核心设计。读完你会知道如何在订单、库存这类规则密集的业务中落地一个既经得起验证、又能持续生长的领域模型。还有一个背景让这个话题比五年前更重要AI 辅助编码已经进入日常开发。大模型可以快速生成大段代码但它并不真正理解你的业务规则。一个没有验证层、没有状态机约束的模型很容易被一段“看起来合理”的生成代码绕开所有防御逻辑。反过来一个可验证的领域模型正好充当 AI 生成代码的护栏。这也是我为什么重提“领域模型”这个老话题。1. 为什么大部分领域模型项目会“烂尾”先泼一盆冷水。过去十年尝试过领域驱动设计的团队不少真正跑通并长期受益的不多。最常见的烂尾路径有三种。第一种是贫血模型。实体类里只有属性和 getter/setter订单状态随便改金额计算放在 Service 里业务规则被拆成一条条 if-else。这样的模型只是数据库表的内存映射谈不上领域模型。第二种是上帝类。模型确实有行为但团队把所有规则都塞进聚合根一个类几千行订单、支付、库存、优惠的逻辑全搅在一起。表面上是“充血模型”实际上是“充血过度”。第三种是模型失真。建模阶段输出的图很漂亮但代码实现和模型设计完全对不上业务一变化模型图就作废了。最后团队连“领域模型到底长什么样”都说不清楚。烂尾的原因不是 DDD 本身有多难而是缺少两条工程纪律。第一模型没有被验证。业务规则只在开发者的脑子里没有用测试锁死。改一处状态判断可能悄悄破坏另一条业务规则。第二模型没有扩展机制。所有新需求都靠“改现有聚合根”完成核心模型越改越重最终谁都不敢动。所以真正决定领域模型能不能长期演化的不是建模方法而是可验证和可扩展这两个工程属性。模型可验证团队才有信心改它扩展机制开放团队才有空间加新能力而不破坏旧能力。2. 可验证给领域模型装上安全阀2.1 什么是“可验证”的领域模型一个领域模型是可验证的意味着它的核心业务规则可以不启动完整应用、不连接数据库就能被自动化测试快速确认。拿订单状态机举例。订单只能从“已创建”变成“已支付”从“已支付”变成“已发货”已发货订单不能取消已完成订单不能重复支付。这些规则如果只写在 Service 的 if 判断里验证起来很麻烦。如果把它们收敛到聚合根并用测试覆盖一条命令就能确认所有规则仍然成立。这里说的“可验证”不是指写几个单元测试那么简单而是一种工程习惯业务规则要有明确的代码归宿并且每个关键规则都有测试作为存证。2.2 可验证的三个层次层次关注点实现手段实际例子语法层非法状态能不能被类型系统阻止值对象、私有构造器、不可变集合订单行数量不能为负构造时就拒绝语义层业务规则运行期是否成立状态机校验、不变量检查已发货订单不能取消否则抛异常行为层系统行为是否符合业务预期单元测试、契约测试、事件回放创建订单必须产生订单创建事件语法层的意义是让非法状态“无法表达”。使用值对象而不是裸的 int/String可以把一批校验从运行期提前到构造期。语义层是领域模型自己的看门人状态流转不合法就直接拒绝。行为层则把前两层变成团队可回归的测试资产。2.3 可验证为什么决定扩展上限这两个概念是因果关系。没有可验证能力的模型扩展是高风险操作。你新加一个“部分退款”状态可能影响已有的支付、发货、对账逻辑但没有任何测试会告诉你哪里被破坏。最后只能靠人工回归而人工回归在大规模业务下不可持续。反过来有了验证层之后模型内部的重构、拆分、优化都会变得安全。测试像结构工程师手里的承重墙图纸告诉你哪些墙可以拆哪些墙不能动。因此可验证能力直接决定了模型能走多远。3. 能力扩展无上限稳定内核与开放扩展“无上限”很容易被误解成“想加什么就加什么”。真正可持续的扩展需要先有一个稳定的内核再在这个内核外围开放若干扩展点。域模型的内核承载的是业务本质规则比如订单状态机、金额计算主流程。这些规则变化频率低即使变化也要走严格评审。扩展点承载的是外围业务能力的叠加比如优惠策略、消息通知、积分赠送、库存联动。新需求优先落在扩展点上而不是修改内核。从这个视角看扩展无上限的本质是设计“内核稳定、外围开放”的架构。3.1 扩展点一领域事件驱动消费者扩展领域事件是领域模型中发生过的业务事实通常用过去时命名比如 OrderCreated、OrderPaid。核心聚合根只负责在状态变化时记录事件不负责具体消费者是谁。新业务模块想在订单支付成功后做点什么不需要改订单聚合根只要新增一个事件监听器即可。这就是无侵入扩展。3.2 扩展点二策略化规则扩展业务规则里有一部分是容易变化的比如折扣规则、运费规则、风险控制规则。这类规则适合抽成“策略接口 多个实现类”新增一种规则时新增实现类并注册而不是修改聚合根。策略模式在这里不是单纯的“设计模式炫技”它解决的是“规则变化不污染核心模型”的工程问题。3.3 扩展点三防腐层与协议适配外部系统的模型和内部领域模型很少能直接对齐。三方支付渠道有各自的回调协议物流系统有自己的运单状态ERP 有自己的商品目录。防腐层Anti-Corruption Layer的作用是在两个模型之间做转换避免外部概念污染内部模型。实现上通常表现为网关接口加适配器。领域模型只依赖自己定义的接口不关心外部协议。需要接入新的渠道时新增一个适配器实现模型主体完全不动。换个角度看这也是一种“能力扩展无上限”外部生态能接多少取决于适配层的扩展能力而不是领域模型本身。3.4 大模型时代的扩展验证即护栏AI 辅助编程普及后领域模型的扩展方式多了一个新场景。开发者可以让 AI 生成新的策略实现、事件监听器、适配器代码。但 AI 生成代码的质量并不稳定尤其容易在状态判断上“自由发挥”。一个可验证的领域模型恰好提供了护栏。AI 生成的代码如果违背了聚合根的状态机约束测试会失败违背了值对象的不变量构造时会抛异常。模型验证越充分AI 能被允许发挥的空间就越大。所以可验证不只是为了人类开发者重构也是为了安全地与 AI 协作。4. 从业务需求到可验证领域模型落地五步法4.1 识别不变量与状态机第一步不是写类而是从业务描述中找“不变量”和“状态机”。不变量是任何时候都必须成立的条件比如“订单金额不能小于 0”“优惠金额不能超过订单金额”。状态机描述对象合法流转路径。把这些内容列成清单它们就是后续测试用例的来源。4.2 用值对象消灭魔法值不要把所有字段都设计成 String、Integer、BigDecimal。业务上有约束、有单位、有语义的字段适合包装成值对象。值对象自带构造校验让非法数据在进入模型之前就被拦截。这一步看起来简单却能显著提升模型的表达力。4.3 收敛规则到聚合根聚合根是领域模型的核心入口。外部应用服务不能直接修改订单内部的商品行只能通过订单聚合根提供的方法操作。所有改变状态的动作都必须经过聚合根内部的状态机校验。这条纪律决定了模型的可验证性。4.4 设计事件出口聚合根内部状态变化后把领域事件记录下来再由事务边界统一发布。这样做可以让事件发布与业务原子操作保持一致避免“状态已保存但事件没发出去”的经典问题。4.5 测试先行规则先行规则识别出来后可以先写测试再用测试驱动聚合根实现。这样写出来的代码每一个方法都有明确目的不会出现多余的公共 setter。5. 完整示例一个可验证的订单领域模型5.1 环境准备与工程结构示例使用纯 Java 17 加 JUnit 5不依赖 Spring Boot 也能运行。如果你使用 Maven只需补齐测试依赖事件监听部分我会用 Spring 的注解演示扩展思路但核心聚合根与测试不依赖 Spring。这里不写死依赖版本保证与你本地工程兼容。目录结构如下src/main/java/com/example/domain/order/ ├── Order.java ├── OrderLine.java ├── OrderStatus.java └── event/ ├── OrderCreatedEvent.java └── OrderStatusChangedEvent.java src/main/java/com/example/domain/order/policy/ ├── DiscountPolicy.java └── DiscountPolicyChain.java src/test/java/com/example/domain/order/ └── OrderTest.java5.2 值对象与状态枚举先定义订单状态枚举。package com.example.domain.order; public enum OrderStatus { CREATED, PAID, SHIPPED, COMPLETED, CANCELED }再定义订单行值对象。订单行不是实体没有独立标识它的数量和金额必须在构造时校验。package com.example.domain.order; import java.math.BigDecimal; public record OrderLine(String skuId, String productName, int quantity, BigDecimal unitPrice) { public OrderLine { if (quantity 0) { throw new IllegalArgumentException(商品数量必须大于 0); } if (unitPrice null || unitPrice.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(商品单价不能为负); } } public BigDecimal subtotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } }5.3 聚合根实现Order 是聚合根所有状态变化都从内部校验开始。pay、ship 方法首先检查前置状态不合法就抛异常防止外部绕过。package com.example.domain.order; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.UUID; import com.example.domain.order.event.OrderCreatedEvent; import com.example.domain.order.event.OrderStatusChangedEvent; public class Order { private final String orderId; private final String customerId; private final ListOrderLine lines; private BigDecimal totalAmount; private OrderStatus status; private final ListObject domainEvents new ArrayList(); private Order(String orderId, String customerId, ListOrderLine lines) { this.orderId orderId; this.customerId customerId; this.lines new ArrayList(lines); this.status OrderStatus.CREATED; this.totalAmount calculateTotal(lines); } public static Order create(String customerId, ListOrderLine lines) { if (customerId null || customerId.isBlank()) { throw new IllegalArgumentException(客户 ID 不能为空); } if (lines null || lines.isEmpty()) { throw new IllegalArgumentException(订单至少包含一个商品行); } Order order new Order(UUID.randomUUID().toString(), customerId, lines); order.domainEvents.add(new OrderCreatedEvent(order.orderId, order.customerId, order.totalAmount)); return order; } public void pay() { if (status ! OrderStatus.CREATED) { throw new IllegalStateException(只有已创建订单才能支付当前状态: status); } this.status OrderStatus.PAID; this.domainEvents.add(new OrderStatusChangedEvent(orderId, OrderStatus.CREATED, OrderStatus.PAID)); } public void ship() { if (status ! OrderStatus.PAID) { throw new IllegalStateException(只有已支付订单才能发货当前状态: status); } this.status OrderStatus.SHIPPED; this.domainEvents.add(new OrderStatusChangedEvent(orderId, OrderStatus.PAID, OrderStatus.SHIPPED)); } public void cancel() { if (status OrderStatus.SHIPPED || status OrderStatus.COMPLETED) { throw new IllegalStateException(已发货或已完成订单不能取消当前状态: status); } OrderStatus oldStatus status; this.status OrderStatus.CANCELED; this.domainEvents.add(new OrderStatusChangedEvent(orderId, oldStatus, OrderStatus.CANCELED)); } public ListObject pullDomainEvents() { ListObject events new ArrayList(domainEvents); domainEvents.clear(); return events; } public String getOrderId() { return orderId; } public String getCustomerId() { return customerId; } public OrderStatus getStatus() { return status; } public BigDecimal getTotalAmount() { return totalAmount; } public ListOrderLine getLines() { return Collections.unmodifiableList(lines); } private BigDecimal calculateTotal(ListOrderLine lines) { return lines.stream() .map(OrderLine::subtotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } }这段代码里值得注意的细节有三个。第一聚合根的构造器是私有的外部只能通过静态工厂 create 创建订单。这保证了所有订单在创建时都经过统一校验。第二getLines 返回的是不可变视图调用方无法绕过聚合根修改商品行。第三domainEvents 通过 pullDomainEvents 移出应用层拿到事件后统一发布避免事件丢失。5.4 事件对象事件对象是领域事实的载体用过去时命名。package com.example.domain.order.event; import java.math.BigDecimal; public record OrderCreatedEvent(String orderId, String customerId, BigDecimal totalAmount) { }package com.example.domain.order.event; import com.example.domain.order.OrderStatus; public record OrderStatusChangedEvent(String orderId, OrderStatus fromStatus, OrderStatus toStatus) { }5.5 测试代码用测试锁死业务规则package com.example.domain.order; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class OrderTest { Test DisplayName(创建订单时计算总金额并生成创建事件) void should_create_order_and_generate_event() { Order order Order.create(customer-1, List.of( new OrderLine(sku-100, 机械键盘, 2, new BigDecimal(399.00)), new OrderLine(sku-200, 鼠标垫, 1, new BigDecimal(59.00)) )); assertEquals(OrderStatus.CREATED, order.getStatus()); assertEquals(0, new BigDecimal(857.00).compareTo(order.getTotalAmount())); ListObject events order.pullDomainEvents(); assertEquals(1, events.size()); assertTrue(events.get(0) instanceof com.example.domain.order.event.OrderCreatedEvent); } Test DisplayName(已支付订单不能重复支付) void should_not_pay_twice() { Order order Order.create(customer-1, List.of( new OrderLine(sku-100, 机械键盘, 1, new BigDecimal(399.00)) )); order.pay(); assertThrows(IllegalStateException.class, order::pay); } Test DisplayName(已发货订单不能取消) void should_not_cancel_shipped_order() { Order order Order.create(customer-1, List.of( new OrderLine(sku-100, 机械键盘, 1, new BigDecimal(399.00)) )); order.pay(); order.ship(); assertThrows(IllegalStateException.class, order::cancel); } }这三个测试分别验证金额计算、状态机边界和事件生成。以后任何人修改支付、发货逻辑跑一遍测试就能知道有没有破坏规则。5.6 扩展点策略链与事件监听现在模拟一个新增需求引入折扣策略。核心聚合根不需要改只要新增一个策略接口和策略链。package com.example.domain.order.policy; import java.math.BigDecimal; import com.example.domain.order.Order; public interface DiscountPolicy { boolean supported(Order order); BigDecimal discount(Order order); }package com.example.domain.order.policy; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import com.example.domain.order.Order; public class DiscountPolicyChain { private final ListDiscountPolicy policies new ArrayList(); public void addPolicy(DiscountPolicy policy) { policies.add(policy); } public BigDecimal calculateDiscount(Order order) { return policies.stream() .filter(policy - policy.supported(order)) .map(policy - policy.discount(order)) .reduce(BigDecimal.ZERO, BigDecimal::add); } }以后新增会员折扣、节日折扣、渠道折扣都只需要实现 DiscountPolicy 并注册到链里。订单聚合根本身不感知具体策略这就是扩展点带来的弹性。事件扩展同样不需要改动聚合根。假如项目使用 Spring可以这样监听领域事件package com.example.application.listener; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import com.example.domain.order.OrderStatus; import com.example.domain.order.event.OrderStatusChangedEvent; Component public class OrderPaidNotificationListener { EventListener public void onOrderStatusChanged(OrderStatusChangedEvent event) { if (OrderStatus.PAID.equals(event.toStatus())) { // 这里可以发送消息、通知仓储、触发积分系统 // 订单聚合根完全不知道这个监听器的存在 System.out.println(订单已支付: event.orderId()); } } }注意聚合根只负责记账和校验真正的事件消费发生在应用层和基础层。这样设计保证领域模型不被消息中间件、外部服务等细节污染。6. 运行与效果验证如果你使用 Maven在工程根目录执行mvn test预期输出大致包含Tests run: 3, Failures: 0, Errors: 0, Skipped: 0看到 0 Failures、0 Errors说明订单模型的基本状态机和金额计算规则都通过验证。如果失败第一步看失败用例名称。比如“已发货订单不能取消”失败说明某处把状态机判断改掉了。不要直接去改测试掩盖问题而是回到聚合根检查校验逻辑。7. 常见问题与排查思路问题现象可能原因排查方式解决方案测试全过但业务规则还是被绕过规则没收敛到聚合根Service 直接修改字段搜索实体 setter 的调用点去掉公开 setter规则统一沉淀到领域方法事件发不出去或重复发送事件发布与状态保存不在同一事务检查事件发布时机和事务边界先保存聚合再在同一事务内发布事件或使用事务性发件箱聚合根越写越大测试越来越多但模型很重可变的计算规则也被塞进聚合根观察哪些方法属于纯计算和外部策略把折扣、运费等可变规则抽成策略或领域服务新需求总是要改核心模型缺少防腐层外部协议直接映射到领域对象检查外部接口与模型是否直接耦合增加网关接口和适配器隔离外部语义状态流转判断散落多处只做了实体没做状态机收敛搜索 if (status ) 的散落位置统一进入聚合根状态方法并补充测试8. 最佳实践与工程建议8.1 永远不要让聚合根暴露内部可变集合内部集合一旦被直接返回调用方就能绕过模型规则。需要返回时使用 Collections.unmodifiableList 或转换为 List.copyOf。8.2 私有构造器加静态工厂方法强制所有外部代码走统一入口。这能保证创建时校验不被跳过后续要改创建逻辑也只有一个位置。8.3 事件先收集再统一发布聚合根里的事件不要直接发到消息中间件。先由 pullDomainEvents 拉取应用层拿到后统一发布。避免领域对象依赖基础设施。8.4 加新业务能力优先找扩展点每次新需求来了先问自己这应该加到核心模型还是通过策略、事件、适配器扩展如果新需求只是“在订单支付后多通知一个系统”那就是事件监听器的活不是订单聚合根的活。8.5 面向 AI 编程时让测试做约束如果你让 AI 生成领域模型相关代码把现有测试一起提供给 AI并明确要求“不要修改状态机校验逻辑”。AI 生成代码后必须跑测试。可验证模型在这里就是最有效的验收标准。8.6 生产环境变更要留退路涉及领域模型的状态机调整不要直接在线上改代码。先在测试环境验证新规则用灰度发布逐步放开并保留事件日志用于回放比对。模型“可验证”的优势就在这个场景体现出来因为规则都有测试兜底灰度回归成本会低很多。8.7 控制限界上下文边界当系统规模变大不要在一个订单聚合里塞下库存、营销、账号、支付所有逻辑。限界上下文之间通过领域事件和应用服务通信保持各自模型独立。这样才能真正让每个上下文的能力扩展独立进行。9. 总结与下一步可验证领域模型的关键并不是“画出漂亮的领域图”而是把业务规则变成可执行的契约。语法层靠值对象和类型系统挡掉非法输入语义层靠聚合根状态机守住业务流程行为层靠测试锁定每一根承重墙。能力扩展无上限的关键也不是“设计一个万能模型”而是建立稳定的内核和足够多的扩展点。领域事件让消费者可以自由加入策略链让规则可以灵活替换防腐层让外部协议变化不冲击内部模型。再加上 AI 时代的高效编码协作你会发现模型稳定之后业务扩展反而更快。如果这篇文章能给你留下一个行动建议那就是这样从你系统里挑选一个规则最密集的聚合开始先用值对象和状态机把规则收拢再补齐测试。此后每一次扩展都在这些扩展点上做而不是继续往 Service 里堆 if-else。模型稳了上限自然就打开了。