跳到主内容

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 术语。这样换了存储,业务代码零改动。

避坑总结 ​

  1. 聚合宁小勿大。 划成「一个用户聚合包含全部订单」的团队,撑不过两次并发冲突;聚合越小事务越短,跨聚合一致性交给事件最终一致。
  2. 聚合外只引 ID,不做对象引用。 Order 里放 InventoryId 而不是 Inventory 对象,否则聚合边界在代码里立刻形同虚设。
  3. 贫血与充血不是二选一,查询侧不必充血。 列表页、报表走 CQRS 查询通道直接 SQL/DTO,充血模型只服务写路径,为查询硬造聚合是自我折磨。
  4. 领域事件要保证至少一次投递与幂等消费。 事件表 + 后台投递是可靠起步方案,消费端按事件 ID 去重,别指望消息队列替你保证精确一次。
  5. 先统一语言,再写代码。 团队还在用「那个单子」「状态改一下」交流时写出的聚合,只会把混乱固化进类型系统;事件风暴半天,比返工三个月便宜。

收尾 ​

DDD 落地不是一次性的架构革命,而是持续的边界治理:先把一个最痛的上下文做充血重构,让团队尝到「规则有唯一出处、测试只测模型」的甜头,再逐步推广。判断做得好不好有一个朴素标准:产品经理描述的新需求,你能不能指出它落在哪里——能,说明代码结构与业务结构对齐了;不能,说明还停留在数据袋子时代。更深层的收益是团队对齐:写进代码的限界上下文词汇会沉淀为团队共同语言,沟通成本随系统演进而降低,而不是升高。架构的价值不在于概念多新,而在于半年后的变更成本有多低。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。