领域驱动设计(DDD)入门:限界上下文、实体、值对象、聚合根在交易系统中的映射

说实话,很多团队做微服务改造,第一步就栽了。

为什么?因为他们把微服务拆分当成了「按功能模块切代码」。结果呢?服务拆了一堆,但改一个需求要改七八个服务,比原来还痛苦。

我当年带团队做交易系统重构时,也踩过这个坑。后来痛定思痛,老老实实学了DDD。说白了,DDD就是教你怎么把业务边界划清楚,让每个服务各管各的一亩三分地。

限界上下文:微服务的「边界感」

限界上下文这个概念,我理解起来花了点时间。后来想通了——它就是微服务的「边界」。

你想想看,一个交易系统里,「订单」这个词在不同场景下含义完全不同:

  • 下单时,订单包含商品、价格、收货地址
  • 支付时,订单关心的是金额、支付状态
  • 物流时,订单关注的是发货状态、快递单号

如果用一个「订单模型」去覆盖所有场景,那这个模型就会变得臃肿不堪。我在项目中见过一个Order对象,里面塞了80多个字段,一半以上是null。

核心原则:每个限界上下文内,模型是完整的、自洽的。上下文之间通过「上下文映射」来协作。

交易系统中典型的限界上下文划分:

限界上下文 核心职责 典型模型
订单上下文 订单创建、状态流转 订单、订单项、收货地址
支付上下文 支付处理、退款 支付单、交易流水
库存上下文 库存扣减、预占 库存、库存流水
物流上下文 发货、配送 运单、物流轨迹

每个上下文都有自己的语言——这叫「通用语言」。团队内部沟通时,就用这套语言。比如订单上下文的人说「下单」,支付上下文的人说「支付」,各说各话,但通过接口对齐。

实体:有「身份证」的对象

实体是什么?简单说:有唯一标识,且会变化的对象。

在交易系统里,最典型的实体就是订单。每个订单有一个订单号,这个号从创建到完结都不会变。但订单的状态、金额、收货地址都会变。

// 订单实体
public class Order {
    private OrderId orderId;      // 唯一标识,不变
    private OrderStatus status;   // 状态,会变
    private Money totalAmount;    // 金额,会变
    private List<OrderItem> items; // 订单项,会变
    
    // 行为:修改状态
    public void changeStatus(OrderStatus newStatus) {
        // 状态校验逻辑
        this.status = newStatus;
    }
}

判断一个对象是不是实体,就看它有没有「身份」——两个对象即使属性一模一样,只要ID不同,就是不同的实体。比如两个用户都叫张三,但ID不同,那就是两个人。

我的习惯:实体的ID不要用数据库自增ID。我一般用分布式ID生成器,或者UUID。这样在领域层就能确定身份,不用等数据库返回。

值对象:没有「身份证」,只有「值」

值对象和实体最大的区别:它没有唯一标识,只关心「是什么」,不关心「是谁」。

举个例子:金额。100元和100元,只要数值和币种一样,就是同一个金额。你不需要给每个金额分配一个ID。

// 值对象:金额
public class Money {
    private final BigDecimal amount;
    private final String currency;
    
    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    
    // 值对象是不可变的
    public Money add(Money other) {
        // 币种校验
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    // 重写equals和hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.equals(money.amount) && currency.equals(money.currency);
    }
}

交易系统里常见的值对象:

  • 地址:省、市、区、详细地址——两个地址一样就是一样
  • 金额:数值+币种
  • 时间段:开始时间+结束时间
  • 联系方式:手机号、邮箱
我曾经踩过的坑:把值对象做成了可变对象。结果两个地方引用同一个地址对象,改了一处另一处也跟着变了,查了半天bug。记住:值对象一定要不可变!

聚合根:数据一致性的「守门员」

聚合根这个概念,我刚开始觉得有点玄乎。后来做交易系统时,被数据不一致的问题折磨得够呛,才真正理解它的价值。

聚合根说白了就是:一组对象的「老大」。外部只能通过聚合根来操作这组对象,不能直接操作内部成员。

在交易系统里,订单就是一个典型的聚合根:

  • 订单包含订单项(OrderItem)
  • 你不能直接修改订单项的数量,必须通过订单来操作
  • 订单负责保证所有订单项的总金额 = 订单总金额
// 聚合根:订单
public class Order {
    private OrderId id;
    private List<OrderItem> items;
    private Money totalAmount;
    
    // 外部只能通过这个方法操作订单项
    public void addItem(Product product, int quantity) {
        // 校验商品是否存在
        // 校验库存
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
        // 重新计算总金额
        this.totalAmount = calculateTotal();
    }
    
    // 内部方法,外部不可见
    private Money calculateTotal() {
        return items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);
    }
}

聚合根的设计原则:

  1. 一致性边界:一个事务只修改一个聚合根
  2. 引用通过ID:聚合根之间通过ID引用,不要直接持有对象引用
  3. 最终一致性:跨聚合根的操作,用事件驱动实现最终一致
实战经验:我见过最糟糕的设计,是一个聚合根里塞了订单、支付、物流三个上下文的数据。结果一个订单修改,锁了三个表,并发一高就死锁。记住:聚合根要小,一个聚合根只负责一个业务单元的一致性。

交易系统中的映射实战

好了,理论说完了。咱们看看在交易系统里,这些概念怎么落地。

我画了一张图,帮你理清关系:

交易系统DDD核心概念映射 订单上下文 聚合根:订单 (Order) orderId, status, totalAmount 实体:订单项 (OrderItem) itemId, productId, quantity 值对象:金额 (Money) amount, currency 值对象:地址 (Address) province, city, detail 支付上下文 聚合根:支付单 (Payment) paymentId, orderId, amount 实体:交易流水 (Transaction) txId, type, status 值对象:支付方式 (PayMethod) type, account, channel 通过orderId引用 聚合根 实体 值对象 跨上下文引用

从这张图你能看到:

  • 订单上下文和支付上下文是独立的限界上下文
  • 每个上下文内部有自己的聚合根、实体、值对象
  • 上下文之间通过ID引用,不直接持有对象
  • 聚合根是外部访问的唯一入口
我的建议:刚开始做DDD映射时,别追求完美。先画出限界上下文,再找聚合根,最后填充实体和值对象。迭代着来,比一次到位靠谱得多。

嗯,DDD的这些概念,说白了就是帮我们把「业务复杂度」转化成「代码结构」。限界上下文划清边界,聚合根守住一致性,实体和值对象各司其职。这套组合拳打下来,交易系统的微服务改造就有了扎实的理论基础。


公众号:蓝海资料掘金营,微信deep3321