news 2026/8/31 12:13:05

可验证领域模型:用测试与扩展机制打开业务能力上限

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
可验证领域模型:用测试与扩展机制打开业务能力上限

很多团队做领域模型,做着做着就做成了“带 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.java

5.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 List<OrderLine> lines; private BigDecimal totalAmount; private OrderStatus status; private final List<Object> domainEvents = new ArrayList<>(); private Order(String orderId, String customerId, List<OrderLine> 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, List<OrderLine> 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 List<Object> pullDomainEvents() { List<Object> 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 List<OrderLine> getLines() { return Collections.unmodifiableList(lines); } private BigDecimal calculateTotal(List<OrderLine> 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())); List<Object> 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 List<DiscountPolicy> 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。模型稳了,上限自然就打开了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 12:13:00

VMware虚拟机安装教程:VMtools与系统镜像完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:09:17

网易Java提前批笔试复盘:考点解析与备考策略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:08:25

DeepSeek Harness与Cordis插件架构:构建可扩展AI工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:07:31

RTS寻路工程实践:A*、JPS与Wall-tracing的组合优化

简介&#xff1a;这是一份面向游戏开发初学者与中级C程序员的实时战略&#xff08;RTS&#xff09;游戏路径规划实战代码包&#xff0c;聚焦于网格地图下的高效寻路问题&#xff0c;涵盖A*、JPS及JPS三种离散层粗略搜索算法&#xff0c;以及源自《Dota 2》的Wall-tracing连续空…

作者头像 李华
网站建设 2026/8/31 12:07:00

8.3 开发流程与测试方法

邓立国多模态Agent开发必读书《多模态AI Agent开发实践》全文试读~持续更新-CSDN博客 目录 8.3.1 标准化开发流程&#xff08;6步落地&#xff09; 8.3.2 核心测试方法 基于前文的需求分析与架构设计&#xff0c;本节将明确多模态智能体的标准化开发流程&#xff0c;结合指…

作者头像 李华
网站建设 2026/8/31 12:06:51

RAG检索增强生成:RRF融合BM25与向量检索的混合检索实践

在实际的 RAG 检索增强生成问答系统中&#xff0c;检索质量往往比 Prompt 技巧更早决定答案上限。很多项目第一次跑通时&#xff0c;用的是“文档切块 向量化 向量数据库召回 拼接 Prompt 让大模型回答”这条链路。演示环境里效果不错&#xff0c;一旦换成真实知识库&#x…

作者头像 李华