资讯动态

可验证领域模型与能力扩展:用订单场景落地DDD扩展机制

发布时间:2026/8/31 7:34:05 来源:尧图企业网站定制
在做领域驱动设计落地时很多团队最容易卡住的地方不是怎么画聚合根也不是怎么建值对象而是领域模型写完第一版后后续每次加需求都要动原来的核心类。订单模型里加优惠要改 Order支付模型里加渠道要改 Payment风控规则多一点就开始堆 if-else。于是很多人对“可验证领域模型”产生误解以为它是写一堆带断言的模型工具类或者认为“能力扩展无上限”就是无限堆类。这两种理解都偏了。这篇文章以订单交易场景为例讲清楚三件事可验证领域模型到底验证什么能力扩展无上限在工程上靠什么机制实现以及如何用一套可复现的代码结构在新增能力时不需要改领域核心类又能通过测试证明模型没有回归。内容偏落地适合已经开始使用 DDD 或正在调整领域模型结构的中级 Java 工程师。文章会给出聚合根、值对象、领域事件、策略注册表、责任链管道和契约测试的最小可运行示例同时标记哪些做法在开发环境和生产环境要区别对待。能力扩展无上限并不能靠抽象思维本身解决。它依赖一组具体机制策略接口封装变化点、注册表避免硬编码分支、管道让能力按顺序叠加、事件监听让外部扩展不侵入主流程。配合领域不变式的校验才能保证无论扩展多少能力模型的基础约束仍然是成立的。下面就从问题本身开始。1. 可验证领域模型到底在解决什么问题1.1 从贫血模型和 if-else 膨胀说起贫血模型是指领域对象里只放数据没有行为业务逻辑全写在 Service 里。典型表现是Order 实体只有 id、status、amount 字段有 getter 和 setterOrderService 里有几十个方法每个方法先 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 ListOrderLine lines; private Money totalAmount; public Order(String orderId, ListOrderLine 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 ListOrderLine getLines() { return Collections.unmodifiableList(lines); } }代码里有一个容易被忽略的点Order 构造后要立即调用 validateInvariants状态变化后也要调用。这意味着“总额等于明细合计”这条不变式在 CREATED、PAID、CANCELLED 任意状态下都必须成立。如果后续有人为了支持部分订单行退款而新增状态只要他修改了 lines 或 totalAmountvalidateInvariants 就会在测试之外即时给出反馈。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 不依赖 DomainEventPublisherOrderService 在调用 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 ListPromotionPolicy policies new ArrayList(); public void register(PromotionPolicy policy) { policies.add(policy); } public Money execute(Money orderTotal, String orderId) { Money result orderTotal; ListPromotionPolicy 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(ListPromotionPolicy 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 MapString, 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 ListOrderPipelineStep 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() { ListOrderLine 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() { ListOrderLine 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() { ListOrderLine 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 或自定义状态机对象但底层原则相同状态转移合法表要集中维护非法转移要立即抛异常。状态流转校验表可以这样梳理当前状态执行动作目标状态是否允许校验条件CREATEDpayPAID是支付金额不小于订单总额CREATEDcancelCANCELLED是取消原因不能为空PAIDcancelCANCELLED是未发货或已走退款流程PAIDpayPAID否重复支付FULFILLEDcancelCANCELLED否已完成订单不能取消CANCELLEDpayPAID否已取消订单不能支付这张表应该作为需求评审和代码审查的输入。模型里每新增一个状态或动作都要先更新表再写对应的禁止路径测试。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() { ListOrderLine 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 例子出发尝试新增一个“赠品策略”或“分期支付策略”再跑一遍契约测试体会“改代码”和“加代码”的区别。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价