ElIauk

DDD 领域驱动设计入门

DDD 解决的是复杂度失控 领域驱动设计经常被讲成一堆名词:聚合、值对象、领域事件、仓储。但它真正要解决的是一个问题:当业务规则越来越复杂,代码里的 if 越来越多,谁都不敢改的时候,怎么让代码结构重新对应业务结构。 它分两层。战略层关注“系统该切成几块、边界在哪、每块内部用什么语言描述业务”,这一

评论
DDD 领域驱动设计入门

DDD 解决的是复杂度失控

领域驱动设计经常被讲成一堆名词:聚合、值对象、领域事件、仓储。但它真正要解决的是一个问题:当业务规则越来越复杂,代码里的 if 越来越多,谁都不敢改的时候,怎么让代码结构重新对应业务结构。

它分两层。战略层关注“系统该切成几块、边界在哪、每块内部用什么语言描述业务”,这一层和微服务拆分是同一件事的两面。战术层关注“一块边界内部怎么组织代码”,产出实体、值对象、聚合、领域服务、仓储。只学战术层,很容易把简单系统做复杂;只谈战略层,代码依旧是贫血的 CRUD。

判断要不要用 DDD,看一个信号:业务规则是否多到需要在多个入口重复实现。如果只是增删改查加几个校验,直接用三层架构更省事。

限界上下文、聚合与聚合根

限界上下文是语言边界:在“订单”上下文里,“商品”指的是下单时的快照(名称、价格、优惠),在“商品”上下文里,“商品”指的是可售卖单元(SKU、库存、上下架)。两者同名不同义,硬合并成一个模型必然打架。

聚合是一致性边界,不是“一组相关的表”。聚合内的数据在一次事务里保持强一致,聚合之间只能最终一致。聚合根是外部访问的唯一入口,外部只能拿到聚合根的 ID,不能直接改内部对象。

聚合划分的两条经验:

  • 聚合要小。一个订单聚合只包订单本身和订单行,不要把支付流水、物流轨迹塞进来,否则每次操作都要加载一大堆数据,还容易产生锁竞争。
  • 跨聚合引用 ID,不引用对象。订单里放 userId,不放 User 对象,这样聚合之间没有加载依赖。

把不变式写进聚合根

“支付金额必须等于应付金额”“已取消的订单不能支付”这类规则,如果散落在 Service 里,总有一天会在某个新入口被漏掉。把它们收进聚合根,让非法状态在代码层面无法构造出来:

public class Order {

    private final OrderId id;
    private final Long tenantId;
    private final List<OrderItem> items;
    private OrderStatus status;

    private Order(OrderId id, Long tenantId, List<OrderItem> items, OrderStatus status) {
        this.id = id;
        this.tenantId = tenantId;
        this.items = List.copyOf(items);
        this.status = status;
    }

    public static Order place(Long tenantId, List<OrderItem> items) {
        Objects.requireNonNull(tenantId, "tenantId 不能为空");
        if (items == null || items.isEmpty()) {
            throw new DomainException("订单至少需要一件商品");
        }
        return new Order(OrderId.next(), tenantId, items, OrderStatus.CREATED);
    }

    public void pay(Money paid) {
        if (status != OrderStatus.CREATED) {
            throw new DomainException("当前状态不允许支付: " + status);
        }
        if (paid.compareTo(totalAmount()) < 0) {
            throw new DomainException("支付金额小于应付金额");
        }
        this.status = OrderStatus.PAID;
    }

    public void cancel(String reason) {
        if (status == OrderStatus.PAID) {
            throw new DomainException("已支付订单需要走退款流程");
        }
        if (status == OrderStatus.CANCELLED) {
            return;
        }
        this.status = OrderStatus.CANCELLED;
    }

    public Money totalAmount() {
        return items.stream()
                .map(OrderItem::subtotal)
                .reduce(Money.ZERO, Money::add);
    }
}

Money 应该是值对象:不可变、用 BigDecimal 存金额、按币种比较、相等性按值判断,这样 compareTo 不会因为浮点误差出问题。用 record 写值对象非常合适。

注意 List.copyOf:聚合内部集合一旦对外暴露可变引用,外部就能绕过聚合根改状态,不变式直接失效。所有返回集合的方法都要返回不可变视图。

仓储与防腐层

仓储的职责是“把聚合持久化”,不是“把表映射成对象”。这意味着:

  • 仓储的入参和返回值是聚合,不是 DO;
  • 一个聚合一次加载,不做懒加载穿透;
  • 查询复杂列表时用专门的读模型,不必强行套聚合。

领域对象和数据库表结构往往不一致(值对象要拆列、事件要单独存表),中间加一层转换是正常的,不算过度设计。

防腐层用在系统边界:外部支付网关的报文、老系统的 DTO、第三方接口的模型,都不要直接传进领域层。包一层翻译,外部变更就被挡在边界之外。

落地节奏

不要一上来就全系统 DDD。比较稳的顺序是:先挑一个业务规则最密集的核心域(通常是订单、计费、风控),在它内部做聚合建模,外围继续用原来的三层结构;跑顺之后,再考虑用领域事件解耦周边,最后才谈 CQRS 和事件溯源。

绝大多数系统一辈子都不需要事件溯源。它解决的是“需要完整审计与任意时点回溯”的问题,代价是事件版本演进、快照、重放一致性,这些成本远超一般业务的收益。

常见坑

把一张表映射成一个聚合。 结果聚合数量暴增,每个聚合都是贫血的 getter/setter 集合,业务规则还是散在 Service 里。

聚合过大。 一个聚合包住订单、支付、发票、物流,任何操作都要全量加载,事务时间变长,锁竞争加剧。

一个事务改多个聚合。 本地事务跨聚合,聚合边界就失去意义了,后面拆库拆服务时会被这块强耦合卡死。

领域事件当同步 RPC 用。 事件本意是解耦,如果在监听器里同步调用其他服务并等待结果,等于换了个写法的耦合,还多了一层不可见的失败路径。

通用语言没落地。 业务叫“结算单”,代码里叫 BillDO,注释里叫“账单”,三个月后没人能确认这三者是不是一回事。

为了 DDD 而 DDD。 一个只有增删改查的配置管理后台,套上聚合、仓储、领域事件,代码量翻三倍,bug 也翻三倍,收益为零。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者