外观
DDD 落地避坑实录:聚合根边界划分与贫血到充血的重构路径
问题背景:业务规则散落在一个个 Service 里
接手一个跑了三年的订单系统:OrderService 两千多行,创建订单校验金额的逻辑在 Controller 里,状态流转判断在 Service 里,积分规则在另一个工具类里;想改「待支付订单 30 分钟未确认自动取消」,要全文检索五个文件才能确认没有遗漏分支。更糟的是没有单测敢动这段代码——业务规则没有任何一处集中表达,每处 if-else 都可能是隐藏的规则。这就是贫血模型的代价:对象只是一堆 getter/setter 的数据袋子,真正承载业务的逻辑全部堆在过程式的 Service 里,复杂度没有消失,只是被摊成了碎片。DDD 的核心主张恰恰相反:把业务规则收拢到领域模型,让代码结构映射业务结构,复杂业务才有被持续维护的可能。
方案对比:先做战略设计,再谈战术落地
DDD 分两层:战略设计回答「系统怎么切」——用事件风暴梳理业务流程,把领域划成限界上下文(商品、订单、库存、营销),明确上下文之间的协作关系;战术设计回答「上下文内部怎么写」——聚合根、实体、值对象、领域事件、仓储。多数团队失败在跳过战略直接套战术:不谈边界就引入聚合,最后聚合变成了「两张表的 ORM 映射」换个名字。上下文之间还要画清协作方式——订单调用库存是防腐层隔离,还是直接共享模型?这些映射关系定了,微服务拆分时才有依据可循。正确的推进顺序是:先用事件风暴对齐团队语言(同一个词在订单上下文和商品上下文含义不同,就叫它不同的名字),再在单个上下文内做充血重构。还要明确 DDD 的适用边界:核心域(赚钱的、差异化的业务)值得全套 DDD;支持域用简单 CRUD 足矣;通用域直接买现成服务。全域 DDD 是灾难性的过度设计。
实操步骤:从识别聚合到充血重构的三步走
第一步:用两个问题判定聚合边界。 「这两个概念的变更是否必须同生共死?」订单和订单项是(改金额必须一起变),订单和库存不是(扣库存是独立的库存上下文的事);「跨对象的不变量校验是否需要事务?」下单时订单行金额之和必须等于订单总额,这是聚合内不变量,放一个聚合。结论:Order 聚合 = Order 根 + OrderItem 实体 + Address/Money 值对象;库存、积分都是外部上下文,聚合内只存 ID。
值对象是充血化过程中最容易被忽略、收益却最直接的一步。金额、地址、手机号这类概念,用 String 和 BigDecimal 散落在各处时,校验规则就会反复出现——每个用到金额的方法都要自己记得判负数。把它们定义为不可变的值对象,构造时集中校验(Money.of(-1) 直接抛异常),合法状态在构造瞬间就得到保证,使用方再也不需要防御式判断。订单重构中我们抽出了 Money、Address、DeliveryWindow 三个值对象,原先散落在六处的金额校验代码随之消失,这正是领域模型表达力提升带来的直接收益。判断一个概念该不该做成值对象有个朴素标准:它只由属性值决定身份、且携带校验规则,就值得。
第二步:把规则迁回聚合,完成充血化。
java
public class Order {
private OrderId id;
private OrderStatus status;
private List<OrderItem> items;
private final List<DomainEvent> events = new ArrayList<>();
/** 确认订单:全部业务规则集中在聚合内部表达 */
public void confirm() {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new DomainException("仅待支付订单可确认,当前状态:" + status);
}
if (items.isEmpty()) {
throw new DomainException("空订单不可确认");
}
// 聚合内不变量:行金额之和必须等于订单总额
Money sum = items.stream().map(OrderItem::subtotal)
.reduce(Money.ZERO, Money::add);
if (!sum.equals(totalAmount())) {
throw new DomainException("订单金额与明细不一致");
}
this.status = OrderStatus.CONFIRMED;
// 记录领域事件,交给应用层在事务提交后发布
this.events.add(new OrderConfirmed(id, totalAmount()));
}
public List<DomainEvent> pullEvents() {
List<DomainEvent> pending = new ArrayList<>(events);
events.clear();
return pending;
}
}Service 从「两千行业务」瘦成「编排」:取聚合、调领域方法、存仓储、发事件,不超过 20 行。第三步:跨聚合协作改用领域事件。 订单确认后扣库存、加积分,不再在同一个事务里同步调用,而是事务提交后发布 OrderConfirmed 事件,库存上下文消费事件异步执行——一个事务只修改一个聚合实例,这是 DDD 对事务边界的硬约束,违反它迟早遇到锁冲突与死锁。
第四步:分层依赖倒置。 仓储接口定义在领域层,实现落在基础设施层(JPA/Mongo 实现),领域层不依赖任何框架注解;接口签名用领域语言(findPendingPaymentOrders)而非 ORM 术语。这样换了存储,业务代码零改动。
避坑总结
- 聚合宁小勿大。 划成「一个用户聚合包含全部订单」的团队,撑不过两次并发冲突;聚合越小事务越短,跨聚合一致性交给事件最终一致。
- 聚合外只引 ID,不做对象引用。 Order 里放
InventoryId而不是 Inventory 对象,否则聚合边界在代码里立刻形同虚设。 - 贫血与充血不是二选一,查询侧不必充血。 列表页、报表走 CQRS 查询通道直接 SQL/DTO,充血模型只服务写路径,为查询硬造聚合是自我折磨。
- 领域事件要保证至少一次投递与幂等消费。 事件表 + 后台投递是可靠起步方案,消费端按事件 ID 去重,别指望消息队列替你保证精确一次。
- 先统一语言,再写代码。 团队还在用「那个单子」「状态改一下」交流时写出的聚合,只会把混乱固化进类型系统;事件风暴半天,比返工三个月便宜。
收尾
DDD 落地不是一次性的架构革命,而是持续的边界治理:先把一个最痛的上下文做充血重构,让团队尝到「规则有唯一出处、测试只测模型」的甜头,再逐步推广。判断做得好不好有一个朴素标准:产品经理描述的新需求,你能不能指出它落在哪里——能,说明代码结构与业务结构对齐了;不能,说明还停留在数据袋子时代。更深层的收益是团队对齐:写进代码的限界上下文词汇会沉淀为团队共同语言,沟通成本随系统演进而降低,而不是升高。架构的价值不在于概念多新,而在于半年后的变更成本有多低。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。