在做领域驱动设计落地时,很多团队最容易卡住的地方,不是怎么画聚合根,也不是怎么建值对象,而是领域模型写完第一版后,后续每次加需求都要动原来的核心类。订单模型里加优惠,要改 Order;支付模型里加渠道,要改 Payment;风控规则多一点,就开始堆 if-else。于是很多人对“可验证领域模型”产生误解,以为它是写一堆带断言的模型工具类,或者认为“能力扩展无上限”就是无限堆类。这两种理解都偏了。
这篇文章以订单交易场景为例,讲清楚三件事:可验证领域模型到底验证什么,能力扩展无上限在工程上靠什么机制实现,以及如何用一套可复现的代码结构,在新增能力时不需要改领域核心类,又能通过测试证明模型没有回归。内容偏落地,适合已经开始使用 DDD 或正在调整领域模型结构的中级 Java 工程师。文章会给出聚合根、值对象、领域事件、策略注册表、责任链管道和契约测试的最小可运行示例,同时标记哪些做法在开发环境和生产环境要区别对待。
能力扩展无上限并不能靠抽象思维本身解决。它依赖一组具体机制:策略接口封装变化点、注册表避免硬编码分支、管道让能力按顺序叠加、事件监听让外部扩展不侵入主流程。配合领域不变式的校验,才能保证无论扩展多少能力,模型的基础约束仍然是成立的。下面就从问题本身开始。
1. 可验证领域模型到底在解决什么问题
1.1 从贫血模型和 if-else 膨胀说起
贫血模型是指领域对象里只放数据,没有行为,业务逻辑全写在 Service 里。典型表现是:Order 实体只有 id、status、amount 字段,有 getter 和 setter;OrderService 里有几十个方法,每个方法先 set 再判断,状态流转散落在各处;Order 对象可以被任何代码改成不该出现的状态。
贫血模型在初期写起来很快,但第一次出现“支付成功后必须修改订单状态、扣减库存、发送通知、记录日志”时,问题就来了。这四件事看起来是四个步骤,实际上它们共享同一个前置条件:订单状态必须是 CREATED,支付金额必须等于或大于订单总额。如果这些规则写在 Service 的不同方法里,任何一处遗漏都会造成状态错乱。
if-else 膨胀是另一个症状。新需求来了,开发者在 Service 里加一个分支:
if (order.getCustomerLevel().equals("VIP")) { discount = new BigDecimal("0.9"); } else if (order.getCustomerLevel().equals("SVIP")) { discount = new BigDecimal("0.8"); } else { discount = BigDecimal.ONE; }这种写法的问题不在于多写了两行判断,而在于每一次新增会员等级都要回到这个已经有很多历史分支的方法里改动。改动的文件越来越多,回归风险越来越高,最后连作者自己都不敢确定“这个分支到底覆盖了哪些业务”。
领域模型的可验证性和能力扩展,本质上就是冲着这两个问题来的:把业务规则收回到模型内,让增加能力不需要修改旧代码。
1.2 “可验证”指的不是写几行测试
可验证的英文对应词是 verifiable。放到领域模型语境里,它至少包含三层含义。
第一层是输入有效,即模型对外部传入的数据有明确约束。金额不能为负、订单不能没有任何订单行、取消订单必须有原因,这些约束要在对象创建或状态切换时立即生效,而不是等到数据库层报错才暴露。
第二层是不变式稳定,即模型的某些规则在任意状态下都不能被破坏。订单总额必须等于所有订单行的小计之和,这是典型的聚合根不变式。无论新增订单、应用优惠还是拆分订单,这个等式都必须成立。如果模型允许外部直接把 totalAmount 改成任意值,这种验证就不存在了。
第三层是可回归,即可以写自动化测试来证明上述两条没有被新功能破坏。契约测试、不变式测试和状态流转测试,都服务于这个目的。只写两个“能跑通”的断言不算可验证,要覆盖正常路径、异常路径和边界值。
与其把可验证理解成“写测试”,不如把它理解成“模型自身提供约束,测试负责证明这些约束持续成立”。好的领域模型即使不运行测试,也会在非法输入出现时尽早抛出异常;测试的价值在于让这些异常成为可预期的行为。
1.3 “无上限”在工程上的真实含义
项目标题里的“无上限”很容易被理解成一个夸张的营销词。在工程上,它应当被翻译成更精确的需求:新增一种能力时,不需要修改已有领域模型类的内部实现,扩展成本不随能力数量线性增长,并且已有行为不受影响。
这里有一个重要判断:真正无上限的系统不存在。数据库连接有限,内存有限,规则引擎的表达式复杂度也有限。工程意义上的无上限,指的是“扩展边界由注册机制决定,而不是由领域模型的分支代码决定”。只要新能力通过策略接口、事件监听或管道步骤加入,Order 的核心逻辑就完全不需要改。
例如要增加一个满减促销,在策略扩展机制下,只需要新增一个实现 PromotionPolicy 的类并注册到 PromotionRegistry。Order 类里没有任何满减判断,OrderService 也不因新增促销而改动。那这个系统的能力扩展就接近无上限:只要注册表支持,理论上可以挂无限多个策略。风险转移到了“策略之间的执行顺序”“策略计算后的金额是否合法”以及“策略本身是否足够快”,这些可以分别通过排序属性、结果校验和性能测试来控制。
一句话总结:无上限不是无约束,而是把约束从散落的 if-else 收敛到明确的扩展机制上。
2. 用订单领域模型跑通可验证设计
2.1 领域模型分层与项目结构
为了把概念落到代码上,这里用一个最小订单系统作为示例。它不依赖复杂框架,核心代码可以在纯 Java 环境中运行,方便单元测试。生产项目中通常还有 Repository、Application Service、Controller 等层,但演示时先聚焦领域层和扩展机制。
建议的模块划分如下:
com.example.order ├── OrderApplication.java ├── domain │ ├── model │ │ ├── Order.java │ │ ├── OrderLine.java │ │ ├── OrderStatus.java │ │ └── Money.java │ ├── event │ │ ├── OrderDomainEvent.java │ │ ├── OrderCreatedEvent.java │ │ └── OrderPaidEvent.java │ ├── policy │ │ ├── PromotionPolicy.java │ │ └── PromotionRegistry.java │ ├── pipeline │ │ ├── OrderPipeline.java │ │ ├── OrderPipelineStep.java │ │ └── OrderPipelineContext.java │ └── service │ └── OrderService.java └── infra ├── repository │ └── OrderRepository.java └── event └── SpringEventPublisher.java这个结构的关键点是:
- domain.model 里只有不依赖 Spring 的类,可以直接在 JUnit 中测试。
- domain.policy 和 domain.pipeline 是扩展点,负责承载会变化的能力。
- infra 里的实现只负责把领域事件发布到 Spring 容器,或在持久化层落地数据。
- 领域层完全不 import 任何 Spring 注解,这样测试不需要 Spring 上下文。
先说明一点,这个分层不是标准 DDD 的唯一答案,但它是“可验证 + 可扩展”比较顺手的结构。实际项目里不要照搬包名,要按照自己的业务和团队规范调整。
2.2 聚合根 Order 的状态与动作
聚合根是领域模型里最重要的对象,它负责维护一组相关对象的一致性边界。这里把 Order 作为聚合根,OrderLine 作为聚合内的实体,外部代码不能绕过 Order 直接修改 OrderLine。
先定义订单状态:
public enum OrderStatus { CREATED, PAID, FULFILLED, CANCELLED; }Order 的实现要遵循一个原则:所有会改变状态的方法都不是简单的 setter,而是表达业务意图的领域行为。支付不是 setStatus(PAID),而是 order.pay(amount);取消不是 setStatus(CANCELLED),而是 order.cancel(reason)。
import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class Order { private final String orderId; private OrderStatus status; private final List<OrderLine> lines; private Money totalAmount; public Order(String orderId, List<OrderLine> lines) { if (orderId == null || orderId.isBlank()) { throw new IllegalArgumentException("订单号不能为空"); } if (lines == null || lines.isEmpty()) { throw new IllegalArgumentException("订单必须包含至少一条订单行"); } this.orderId = orderId; this.status = OrderStatus.CREATED; this.lines = new ArrayList<>(lines); this.totalAmount = lines.stream() .map(OrderLine::getSubtotal) .reduce(Money.of(BigDecimal.ZERO), Money::add); validateInvariants(); } public PaymentResult pay(Money amount) { if (status != OrderStatus.CREATED) { throw new IllegalStateException("只有 CREATED 状态的订单可以支付,当前状态是 " + status); } if (amount.getAmount().compareTo(totalAmount.getAmount()) < 0) { throw new IllegalArgumentException("支付金额小于订单总额,不能完成支付"); } this.status = OrderStatus.PAID; validateInvariants(); return new PaymentResult(orderId, status); } public void cancel(String reason) { if (reason == null || reason.isBlank()) { throw new IllegalArgumentException("取消原因不能为空"); } if (status == OrderStatus.FULFILLED || status == OrderStatus.CANCELLED) { throw new IllegalStateException("当前状态不能取消: " + status); } this.status = OrderStatus.CANCELLED; validateInvariants(); } private void validateInvariants() { if (lines.isEmpty()) { throw new IllegalStateException("订单行不能为空"); } BigDecimal sum = lines.stream() .map(line -> line.getSubtotal().getAmount()) .reduce(BigDecimal.ZERO, BigDecimal::add); if (totalAmount.getAmount().compareTo(sum) != 0) { throw new IllegalStateException("订单总额必须等于所有订单行的合计"); } if (status == OrderStatus.PAID && totalAmount.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalStateException("已支付订单总额必须大于 0"); } } public String getOrderId() { return orderId; } public OrderStatus getStatus() { return status; } public Money getTotalAmount() { return totalAmount; } public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); } }代码里有一个容易被忽略的点:Order 构造后要立即调用 validateInvariants,状态变化后也要调用。这意味着“总额等于明细合计”这条不变式,在 CREATED、PAID、CANCELLED 任意状态下都必须成立。如果后续有人为了支持部分订单行退款而新增状态,只要他修改了 lines 或 totalAmount,validateInvariants 就会在测试之外即时给出反馈。
OrderLine 是聚合内的实体,它不需要有全局唯一标识,但要有自身的业务约束:
import java.math.BigDecimal; public class OrderLine { private final String sku; private final int quantity; private final Money unitPrice; public OrderLine(String sku, int quantity, Money unitPrice) { if (sku == null || sku.isBlank()) { throw new IllegalArgumentException("SKU 不能为空"); } if (quantity <= 0) { throw new IllegalArgumentException("数量必须大于 0"); } this.sku = sku; this.quantity = quantity; this.unitPrice = unitPrice; } public Money getSubtotal() { return Money.of(unitPrice.getAmount().multiply(BigDecimal.valueOf(quantity))); } }这里为什么不用 double 而是用自定义 Money?因为金额计算一旦使用浮点数,就会出现 0.1 加 0.2 不等于 0.3 的精度问题。金额本身的不可变性、非负性和计算精度,都应该由值对象自己保证。
2.3 值对象 Money 与业务不变式
值对象的特点是:没有身份,通过属性值判断相等,并且不可变。Money 就是最典型的值对象。它可以单独抽出来作为公共能力,因为订单、支付、退款、对账都会用到。
import java.math.BigDecimal; public class Money { private final BigDecimal amount; private Money(BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("金额不能为空且不能为负数"); } this.amount = amount; } public static Money of(String value) { return new Money(new BigDecimal(value)); } public static Money of(BigDecimal value) { return new Money(value); } public Money add(Money other) { return new Money(this.amount.add(other.amount)); } public Money subtract(Money other) { BigDecimal result = this.amount.subtract(other.amount); if (result.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalStateException("金额不允许被减成负数"); } return new Money(result); } public BigDecimal getAmount() { return amount; } }这个设计把三条规则固定在了类型内部:金额不能为空,金额不能为负,金额运算必须使用 BigDecimal。外部代码无法绕过这些规则,因为构造方法私有,只能通过 of 工厂方法创建。这就是可验证性的第一道闸门:非法数据在进入模型之前就被拦截。
同时要注意,Money 没有定义 equals 和 hashCode。在真实项目中,值对象需要按 amount 比较相等,否则放进集合或作为 Map 键时会出现诡异行为。这里省略是为了演示领域行为,项目落地时要补上。
2.4 领域事件解耦外部能力
领域事件是扩展机制的重要组成部分。它解决一个问题:订单支付成功后,需要记账、通知履约系统、发送短信,但 Order 不应该知道这些外部系统的细节。Order 只负责修改自身状态,并把“已支付”这件事发布出去,外部能力通过监听事件来响应。
定义事件的根接口:
public interface OrderDomainEvent { String getOrderId(); }下面是支付事件:
public class OrderPaidEvent implements OrderDomainEvent { private final String orderId; private final String paidAmount; public OrderPaidEvent(String orderId, String paidAmount) { this.orderId = orderId; this.paidAmount = paidAmount; } @Override public String getOrderId() { return orderId; } public String getPaidAmount() { return paidAmount; } }领域模型本身只负责产生事件,不负责发布事件。事件发布需要引入一个端口:
public interface DomainEventPublisher { void publish(OrderDomainEvent event); }在 Spring 项目里,可以这样实现:
import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; @Component public class SpringEventPublisher implements DomainEventPublisher { private final ApplicationEventPublisher publisher; public SpringEventPublisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } @Override public void publish(OrderDomainEvent event) { publisher.publishEvent(event); } }这里的关键设计是:Order 不依赖 DomainEventPublisher,OrderService 在调用 Order 的领域行为后,读取结果事件并发布。这样 Order 保持纯领域模型,事件发布属于应用层编排职责。
3. 能力扩展机制:策略、注册表、管道与事件
3.1 策略接口:把变化封装成能力
能力扩展最基础的形态就是策略模式。以促销为例,不同促销规则对订单总价的影响方式不同,但它们可以统一抽象成同一个接口:输入当前总额,返回计算后的总额。
@FunctionalInterface public interface PromotionPolicy { Money apply(Money orderTotal, String orderId); default int order() { return 0; } }order 方法用于控制多个促销策略的执行顺序。默认值为 0,表示该策略不关心顺序;需要保证顺序的策略可以覆盖这个方法,返回一个确定的数字。这样比硬编码 if-else 强在两点:
新增促销时不需要改动 Order 和 OrderService,只需要新增一个类实现 PromotionPolicy。策略之间的顺序由数字显式声明,比在旧代码里寻找插入位置要可控得多。
下面是一个满减策略示例:
import java.math.BigDecimal; public class FullReductionPromotion implements PromotionPolicy { private final BigDecimal threshold; private final BigDecimal reduction; public FullReductionPromotion(String threshold, String reduction) { this.threshold = new BigDecimal(threshold); this.reduction = new BigDecimal(reduction); } @Override public Money apply(Money orderTotal, String orderId) { if (orderTotal.getAmount().compareTo(threshold) >= 0) { return orderTotal.subtract(Money.of(reduction)); } return orderTotal; } @Override public int order() { return 100; } }3.2 扩展点注册表:去掉硬编码分支
有了策略接口,还需要一个地方把多个策略收集起来。最原始的方式是在 OrderService 里 new 出各个策略,这仍然等于硬编码。正确做法是引入注册表,让策略可以在系统启动时或配置加载时注册进去。
import java.util.ArrayList; import java.util.Comparator; import java.util.List; public class PromotionRegistry { private final List<PromotionPolicy> policies = new ArrayList<>(); public void register(PromotionPolicy policy) { policies.add(policy); } public Money execute(Money orderTotal, String orderId) { Money result = orderTotal; List<PromotionPolicy> sorted = new ArrayList<>(policies); sorted.sort(Comparator.comparingInt(PromotionPolicy::order)); for (PromotionPolicy policy : sorted) { result = policy.apply(result, orderId); } return result; } public int size() { return policies.size(); } }在 Spring Boot 项目中,注册表可以作为一个单例 Bean,通过构造器注入所有 PromotionPolicy 的实现:
import org.springframework.stereotype.Component; import java.util.List; @Component public class SpringPromotionRegistry extends PromotionRegistry { public SpringPromotionRegistry(List<PromotionPolicy> policies) { policies.forEach(this::register); } }这种做法的好处是:新增策略只需要声明一个新的 Spring Bean,注册逻辑不用改。Spring 会自动把容器里所有 PromotionPolicy 实现收集起来,通过构造函数注入到注册表中。
这里有一个工程判断要说明:自动收集所有策略在小型项目中很便利,但在大型项目里会出现“这个策略从哪里来的”的困惑。生产环境建议在配置文件中显式声明启用的策略列表,或者为每一个策略增加可开关配置,避免一个 Bean 被无意识加载后影响所有订单。
3.3 责任链管道:让能力按顺序叠加
策略模式适合“结果替换”类的能力,比如促销计算。但有些能力要求“前置校验 + 后置动作”的组合,比如下单前检查库存、下单后生成操作流水。这时候更适合使用责任链管道。
定义管道步骤接口:
public interface OrderPipelineStep { int order(); void handle(OrderPipelineContext context); }管道上下文负责在步骤之间传递数据:
import java.util.HashMap; import java.util.Map; public class OrderPipelineContext { private final Order order; private final Map<String, Object> attributes = new HashMap<>(); public OrderPipelineContext(Order order) { this.order = order; } public Order getOrder() { return order; } @SuppressWarnings("unchecked") public <T> T getAttribute(String key) { return (T) attributes.get(key); } public void setAttribute(String key, Object value) { attributes.put(key, value); } }管道本身不感知业务细节,只负责按顺序执行步骤:
import java.util.ArrayList; import java.util.Comparator; import java.util.List; public class OrderPipeline { private final List<OrderPipelineStep> steps = new ArrayList<>(); public void addStep(OrderPipelineStep step) { steps.add(step); } public void execute(OrderPipelineContext context) { steps.stream() .sorted(Comparator.comparingInt(OrderPipelineStep::order)) .forEach(step -> step.handle(context)); } }假设有一个库存校验步骤和一个订单流水记录步骤:
public class StockCheckStep implements OrderPipelineStep { @Override public int order() { return 10; } @Override public void handle(OrderPipelineContext context) { // 这里调用库存服务,减少库存;失败时抛异常中断后续步骤 } }管道和策略的区别值得说明:策略接口通常是对同一类问题的不同解法,运行时选择一个结果;管道是对同一流程的不同处理阶段,多个步骤需要依次执行。二者可以组合使用。比如库存校验步骤内部,可以再用策略模式区分不同仓库的扣减逻辑。
3.4 领域事件监听器:不改主流程也能扩展
策略和管道都需要在业务执行主链路上被显式调用。领域事件则更进一步,允许外部模块在订单状态变化后自行响应,OrderService 甚至不需要知道有哪些监听器。
在 Spring 中监听领域事件非常简单:
import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @Component public class OrderEventListener { @EventListener public void onOrderPaid(OrderPaidEvent event) { // 发送支付成功短信 // 调用履约系统创建发货单 // 写入对账流水 } @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 下发购物车清理指令 } }使用事件监听会让主流程非常干净,但它引入了事务边界问题。默认情况下,Spring 的 @EventListener 是同步执行的,并且和触发方法处于同一个事务中。如果监听器里调用外部系统,外部系统响应慢,会拖长数据库事务。如果监听器抛出异常,还会导致订单支付事务回滚,这通常不是期望行为。
生产环境推荐的做法是:同步事务事件只做必要的内部状态补充,外部调用放到异步事件或消息队列中。关于这个问题的细节,会在第 5 节展开。
这里先把事件机制的价值说清楚:Order 不依赖促销、不依赖物流、不依赖通知,它只负责支付状态变更。新增一个短信通知能力,只需要新增一个监听器,Order 和 OrderService 都不需要修改。这就是“能力扩展无上限”最直观的表现。
4. 可验证性的多层保障
4.1 不变式校验放在领域层而不是 Controller
很多项目把参数校验写在 Controller 里,用 Bean Validation 注解标注字段非空、金额范围。这种校验属于传输层校验,它只能保证进入接口的数据格式正确,无法保证进入领域层后的业务关系正确。比如 orderId 非空和非空是两个不同层级的校验。
可验证领域模型要求:格式校验放在 Controller,业务不变式放在领域模型。Order 里的 validateInvariants 就是业务不变式,它负责保证:
- 订单行不能为空。
- 订单总额必须等于订单行合计。
- 已支付订单总额必须大于 0。
这些约束不能依赖 Spring、不能依赖数据库,必须在纯 Java 对象里可执行。这样单元测试不需要启动 Spring 容器,创建 Order 实例即可验证。
下面是对应的单元测试:
import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class OrderTest { @Test void totalAmountShouldEqualSumOfLines() { List<OrderLine> lines = List.of( new OrderLine("sku-1001", 2, Money.of("50.00")), new OrderLine("sku-1002", 1, Money.of("30.00")) ); Order order = new Order("order-001", lines); assertEquals(new BigDecimal("130.00"), order.getTotalAmount().getAmount()); } @Test void createdOrderCanBeCancelled() { List<OrderLine> lines = List.of(new OrderLine("sku-1001", 1, Money.of("10.00"))); Order order = new Order("order-002", lines); order.cancel("用户取消"); assertEquals(OrderStatus.CANCELLED, order.getStatus()); } @Test void paidOrderCannotBeCancelledWithoutReason() { List<OrderLine> lines = List.of(new OrderLine("sku-1001", 1, Money.of("10.00"))); Order order = new Order("order-003", lines); order.pay(Money.of("10.00")); assertThrows(IllegalArgumentException.class, () -> order.cancel("")); } }这些测试的名字直接描述业务规则,测试失败时,最先看到的是“哪条业务规则被破坏”,而不是“哪个 setter 返回了空值”。
4.2 状态机约束防非法流转
订单状态不是随便可以跳转的。用枚举加状态判断是最轻量的状态约束方式,适合状态数量少、流转规则简单的模型。
当前示例里 Order 定义了三条状态规则:
- CREATED 可以支付,支付后进入 PAID。
- CREATED 和 PAID 可以取消,取消后进入 CANCELLED。
- FULFILLED 和 CANCELLED 状态不能再次取消或支付。
把这些规则写在领域方法里,而不是写在 Service 的判断条件里,可以防止“绕过模型直接改字段”。如果有更复杂的状态流转,比如退款状态、部分发货、审核中,建议引入 Spring StateMachine 或自定义状态机对象,但底层原则相同:状态转移合法表要集中维护,非法转移要立即抛异常。
状态流转校验表可以这样梳理:
| 当前状态 | 执行动作 | 目标状态 | 是否允许 | 校验条件 |
|---|---|---|---|---|
| CREATED | pay | PAID | 是 | 支付金额不小于订单总额 |
| CREATED | cancel | CANCELLED | 是 | 取消原因不能为空 |
| PAID | cancel | CANCELLED | 是 | 未发货或已走退款流程 |
| PAID | pay | PAID | 否 | 重复支付 |
| FULFILLED | cancel | CANCELLED | 否 | 已完成订单不能取消 |
| CANCELLED | pay | PAID | 否 | 已取消订单不能支付 |
这张表应该作为需求评审和代码审查的输入。模型里每新增一个状态或动作,都要先更新表,再写对应的禁止路径测试。
4.3 契约测试证明扩展不破坏原有行为
策略和管道让扩展变得容易,但容易扩展也意味着容易“悄悄改变行为”。为了证明新增策略没有破坏订单模型的基础规则,需要契约测试。契约测试的核心思路是:不管注册了多少策略,Order 模型本身的约束必须原样成立。
下面是一个契约测试模板:
import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class OrderInvariantContractTest { @Test void invariantContractShouldHoldAfterAnyPaymentOperation() { List<OrderLine> lines = List.of( new OrderLine("sku-2001", 3, Money.of("20.00")), new OrderLine("sku-2002", 1, Money.of("45.50")) ); Order order = new Order("order-contract", lines); order.pay(Money.of("105.50")); BigDecimal expectedTotal = new BigDecimal("105.50"); BigDecimal lineSum = order.getLines().stream() .map(line -> line.getSubtotal().getAmount()) .reduce(BigDecimal.ZERO, BigDecimal::add); assertEquals(expectedTotal, order.getTotalAmount().getAmount()); assertEquals(expectedTotal, lineSum); } }这个测试看起来和普通单元测试很像,但它的定位不同。契约测试强调“无论在哪个扩展场景下都不破坏”,所以在实际项目中,可以把它放在 CI 的一个专门阶段,配合集成测试一起执行。
更进一步,如果项目里引入了 Spring 容器和注册表,可以写一个类似“扩展完整性”的测试:启动容器后,检查注册表中实际生效的策略数量是否符合预期,避免漏注册或重复注册。
4.4 业务结果验证与回滚机制
可验证性不仅体现在写测试,还体现在生产运行时可观测。策略计算后,订单总额可能被改小;优惠过大,可能导致支付金额为 0 或负数。这类极端情况虽然模型里可能被拦截,但策略组合起来之后,结果是否合理需要额外验证。
建议在订单创建流程中增加一个结果校验步骤:
public class OrderTotalValidationStep implements OrderPipelineStep { @Override public int order() { return 50; } @Override public void handle(OrderPipelineContext context) { Order order = context.getOrder(); if (order.getTotalAmount().getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalStateException("订单总金额必须大于 0"); } } }这样即使某个策略计算出零元订单,管道也会在落库前中断,随后由事务回滚机制保证不会产生脏数据。学习环境可以忽略这一步,但生产环境必须设计兜底校验,因为策略是外部扩展,不能假定每个策略都遵守规则。
5. 常见问题与排查路径
5.1 扩展点太多,线上定位不到是哪一段逻辑
现象:一个订单金额被优惠策略计算成异常值,线上日志只记录了“创建订单失败”,但不知道是哪个策略造成的。
原因:策略和管道步骤多了以后,日志不打印执行链路,导致问题出在哪一段无从查起。
排查路径:
- 在注册表 execute 和管道 execute 方法中打印策略名称和每一步执行前的结果。
- 为订单创建流程增加 traceId,贯穿策略、事件和外部调用。
- 使用调试日志级别,记录每一步的类名、订单号、金额前后变化。
预防建议:所有策略和管道步骤的 handle 方法中,至少打印以下内容:
执行策略: FullReductionPromotion, 订单: order-001, 金额: 150.00 -> 120.005.2 事件异步执行导致事务边界混乱
现象:监听器里调用外部系统发送短信,监听器抛出异常后,订单支付事务也被回滚了。
原因:默认的 @EventListener 与业务方法在同一事务中,监听到的异常会触发生成回滚。
解决方案:
- 在监听器内部 try/catch,确保外部调用异常不影响主事务。
- 将外部调用迁到 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT),只在事务提交成功后执行。
- 更彻底的方案是发送到消息队列,由消费者异步处理。
推荐的最小改造:
import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @Component public class OrderEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderPaid(OrderPaidEvent event) { // 事务提交后再做外部通知 } }这种设计的代价是:事件结果和主事务不再强一致。如果短信发送失败,订单已经成功支付,只能通过补偿任务重试。方案选择取决于业务对一致性的要求。
5.3 策略注册遗漏导致行为静默失效
现象:新增了满减策略,但订单金额没有变化,没有报错也没有异常日志。
原因:策略类没有注册到 PromotionRegistry,或 Spring 构造函数注入时没有收集到该 Bean。
排查路径:
- 检查策略类是否被 Spring 容器扫描到。
- 检查 PromotionRegistry 的 size 是否等于预期数量。
- 在注册表 register 方法中打印策略类名,启动时确认所有策略已加载。
预防建议:写一个注册完整性测试,启动容器后断言注册表中的策略数量:
import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest class PromotionRegistryIntegrityTest { @Autowired private PromotionRegistry registry; @Test void allConfiguredPromotionsShouldBeRegistered() { assertEquals(3, registry.size()); } }5.4 领域模型混入 Spring Bean 后难做纯单元测试
现象:Order 里想调用邮件服务、配置服务,于是直接注入 ApplicationContext 或其它 Bean,结果每个单元测试都要启动 Spring 容器。
原因:领域模型承担了不应由它承担的应用层职责。
解决方案:把外部依赖从领域模型中彻底移除。Order 只保留业务状态和业务规则,外部行为通过领域事件、策略接口、管道步骤与应用层协作。如果确实需要配置,把配置作为方法参数传入,而不是在模型内部读取。
这条规则可以写成代码审查项:domain.model 包下的类不能 import springframework 下的任何包。
6. 可复用检查清单与生产建议
6.1 扩展点设计检查清单
在新增一个能力之前,先回答以下问题:
| 检查项 | 通过标准 |
|---|---|
| 是否新增了策略接口或管道步骤 | 是,而不是在现有方法中加 if-else |
| 是否修改了领域核心类 | 否,Order 原有方法不应因新能力而改变 |
| 是否注册了新策略 | 已注册,并有完整性测试确认数量 |
| 是否定义了执行顺序 | 使用 order 值显式声明 |
| 是否增加了结果的兜底校验 | 新策略的结果会被校验步骤检查 |
| 是否更新了状态流转表 | 如果涉及新状态或动作,表格必须同步 |
6.2 可验证性检查清单
可验证性检查要覆盖模型和测试两个层面:
- 构造器是否立即校验不变式。
- 状态变更方法是否再次校验不变式。
- 金额、数量等关键值对象是否封装了非空和非负规则。
- 非法状态流转是否被测试覆盖。
- 契约测试是否在 CI 中独立执行。
- 领域模型是否不依赖 Spring 或数据库。
6.3 学习环境与生产环境的差异
开发学习项目时,重点是跑通机制,可以简化很多生产细节。下面这张表总结了两者的差异:
| 维度 | 学习/开发环境 | 生产环境 |
|---|---|---|
| 策略注册 | 手动 register | 通过配置文件或启动时按条件加载 |
| 事件处理 | 同步监听最简单 | 外部调用用 AFTER_COMMIT 或消息队列 |
| 日志 | System.out 可接受 | 必须使用结构化日志和 traceId |
| 兜底校验 | 可省略 | 必须有极端值保护 |
| 注册完整性测试 | 可跳过 | 必须接入 CI |
| 文档 | 代码注释即可 | 需要扩展点清单和状态机说明 |
6.4 扩展方向:规则引擎、事件溯源与模型审计
当策略数量增长到几十个时,用 Java 类维护每个策略会变得繁琐。此时可以引入规则引擎,把策略规则配置化。常见做法是把满减、折扣、阶梯价这类规则写成表达式,存到数据库或 JSON 配置中,由规则引擎统一解析执行。这样新增规则就不再需要发版,但仍然需要有校验机制,防止表达式计算出负数或超出预期的金额。
事件溯源是另一个扩展方向。当前模型保存的是订单的最新状态,事件溯源则把 OrderCreatedEvent、OrderPaidEvent、OrderCancelledEvent 等全部保存下来,订单当前状态由事件流重放得到。这种设计让“可验证”更进一步:每一步状态变化都有据可查,模型出现了问题可以从源头回放调查。代价是存储和查询复杂度变高,适合对审计要求极高的系统。
最后想强调一个判断:可验证领域模型和无限扩展能力,不是靠一个抽象接口就能得到的,而是靠模型内聚业务约束、扩展点收敛变化、测试证明约束持续成立这三件事共同完成的。如果只学模式不学约束,扩展会变成失控;如果只写测试不拆扩展点,测试会越写越沉重。对新手来说,最有价值的练习不是背 DDD 概念,而是从本文的 Order 例子出发,尝试新增一个“赠品策略”或“分期支付策略”,再跑一遍契约测试,体会“改代码”和“加代码”的区别。