3、领域驱动设计(DDD)基础:实体、值对象、聚合、领域事件

聊到微服务拆分,有个话题绕不开——领域驱动设计,也就是 DDD。说实话,我早年做架构时,觉得 DDD 太「重」了,一堆概念听着就头疼。直到后来接手一个订单系统,业务逻辑乱成一锅粥,改一个需求要动七八个服务……嗯,那时候我才意识到,DDD 不是用来装点门面的,它是真正能帮你理清业务边界的利器。

这一章,咱们就聊聊 DDD 的四个核心概念:实体、值对象、聚合、领域事件。说白了,它们是构建领域模型的「积木」。你搞懂了它们,微服务怎么切、边界怎么画,心里就有谱了。

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

实体是什么?简单说,就是有唯一标识的对象。这个标识贯穿它的整个生命周期,哪怕它的属性全变了,它还是它。

举个例子,一个用户。用户 ID 是 1001,今天他叫张三,明天改名叫李四,他还是那个用户。为什么?因为 ID 没变。这就是实体的核心——身份标识

我在项目中遇到过一种情况:有人把订单的「订单号」和「订单详情」混在一起,全当实体处理。结果订单详情里的商品价格一变,整个订单的版本号就乱了。其实订单详情更应该作为值对象,而不是实体。

实体的特征:
  • 有唯一标识(ID、UUID、业务主键等)
  • 可变(属性可以修改)
  • 通过标识判断相等性,而不是属性
// 实体示例:用户
public class User {
    private UserId userId;  // 值对象作为ID
    private String name;
    private String email;

    public void changeName(String newName) {
        // 业务规则:名字不能为空
        if (newName == null || newName.isBlank()) {
            throw new IllegalArgumentException("名字不能为空");
        }
        this.name = newName;
    }

    @Override
    public boolean equals(Object o) {
        // 只比较ID,不比较属性
        return userId.equals(((User) o).userId);
    }
}
我的习惯:实体的 ID 尽量用值对象封装,比如 UserId、OrderId。这样类型安全,也方便以后扩展。

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

值对象跟实体正好相反。它没有唯一标识,只描述某个事物的属性。两个值对象只要属性一样,就是同一个东西。

你想想看,一个地址。北京市朝阳区某某路 100 号。这个地址本身没有 ID,它就是一个值。两个一模一样的地址,就是同一个地址。你不需要区分「地址 A」和「地址 B」。

值对象有个重要特性——不可变。一旦创建,就不能修改。想改?那就新建一个。这能避免很多并发问题。

值对象的特征:
  • 没有唯一标识
  • 不可变(Immutable)
  • 通过所有属性判断相等性
  • 可以独立存在,也可以作为实体的属性
// 值对象示例:地址
public final class Address {
    private final String province;
    private final String city;
    private final String street;
    private final String zipCode;

    public Address(String province, String city, String street, String zipCode) {
        this.province = province;
        this.city = city;
        this.street = street;
        this.zipCode = zipCode;
    }

    // 只有getter,没有setter

    @Override
    public boolean equals(Object o) {
        // 比较所有属性
        Address address = (Address) o;
        return province.equals(address.province) 
            && city.equals(address.city) 
            && street.equals(address.street) 
            && zipCode.equals(address.zipCode);
    }
}
我曾经踩过的坑:把金额直接定义成 double 类型,结果计算时出现精度问题。后来我强制团队用值对象封装 Money,里面包含金额和币种。从此再没出过类似问题。

3.3 聚合:业务一致性的「边界」

聚合是 DDD 里最实用的概念之一。它定义了一组对象的边界,边界内部保证数据一致性。说白了,聚合就是「一组必须一起保存的对象」。

举个例子,订单和订单项。一个订单有多个订单项。你不能只保存订单项而不保存订单,也不能只保存订单而丢了订单项。它们是一个整体。这个整体就是聚合。

聚合有个关键角色——聚合根。它是聚合的入口,外部只能通过聚合根来操作聚合内部的对象。订单就是聚合根,订单项不是。你想修改订单项?必须通过订单来操作。

聚合的设计原则:
  • 聚合内部保证最终一致性
  • 聚合根是唯一的外部访问入口
  • 一个事务只修改一个聚合
  • 聚合之间通过领域事件通信
// 聚合根:订单
public class Order {
    private OrderId orderId;
    private List<OrderItem> items;
    private OrderStatus status;

    public void addItem(ProductId productId, int quantity, Money price) {
        // 业务规则:已支付的订单不能添加商品
        if (status == OrderStatus.PAID) {
            throw new IllegalStateException("已支付订单不能添加商品");
        }
        items.add(new OrderItem(productId, quantity, price));
    }

    public void pay() {
        // 业务规则:订单不能为空
        if (items.isEmpty()) {
            throw new IllegalStateException("订单为空,无法支付");
        }
        this.status = OrderStatus.PAID;
        // 发布领域事件
        DomainEventPublisher.publish(new OrderPaidEvent(orderId));
    }
}
我的建议:聚合不要设计得太大。一个聚合包含 3-5 个实体/值对象就够了。太大了事务性能差,太小了业务完整性又不够。这个度,得靠经验慢慢把握。

3.4 领域事件:解耦的「消息」

领域事件是 DDD 里用来解耦的利器。当一个聚合的状态发生变化时,它发布一个事件,其他聚合(或外部系统)订阅这个事件并做出响应。

为什么会需要领域事件?你想想看,订单支付成功后,需要做几件事:更新库存、发送通知、记录日志……如果把这些逻辑全写在订单的 pay() 方法里,订单聚合就变得臃肿不堪,而且耦合严重。用领域事件,订单只管发布「OrderPaidEvent」,其他事情让订阅者去处理。

领域事件的特征:
  • 表示已经发生的事实(过去时)
  • 不可变
  • 包含事件发生时的上下文数据
  • 通常异步处理
// 领域事件:订单已支付
public class OrderPaidEvent {
    private final OrderId orderId;
    private final Money paidAmount;
    private final LocalDateTime occurredOn;

    public OrderPaidEvent(OrderId orderId, Money paidAmount) {
        this.orderId = orderId;
        this.paidAmount = paidAmount;
        this.occurredOn = LocalDateTime.now();
    }

    // getter 方法
}

// 事件订阅者:库存服务
public class InventoryEventHandler {
    @EventListener
    public void handle(OrderPaidEvent event) {
        // 扣减库存
        inventoryService.deduct(event.getOrderId());
    }
}
我曾经犯过的错:把领域事件和消息队列混为一谈。领域事件是业务概念,消息队列是技术实现。先定义好事件,再考虑用 Kafka 还是 RabbitMQ 去实现。别搞反了。

3.5 四个概念的关系

这四个概念不是孤立的。它们共同构成了领域模型的骨架:

概念 核心特征 典型例子 我的经验
实体 有唯一标识,可变 用户、订单、商品 ID 用值对象封装
值对象 无标识,不可变 地址、金额、颜色 优先用值对象,减少实体数量
聚合 一致性边界,聚合根 订单+订单项 别太大,也别太小
领域事件 事实记录,解耦 OrderPaidEvent 先定义事件,再选技术

说白了,实体和值对象是「积木」,聚合是「积木拼成的模块」,领域事件是「模块之间的信号」。你把这四样东西搞清楚了,微服务的边界划分就有了理论依据。

最后说一句:DDD 不是银弹。如果你的业务逻辑很简单,CRUD 就够了,没必要硬套 DDD。但一旦业务复杂起来,这些概念能帮你少走很多弯路。我见过太多项目,前期图省事不做领域建模,后期改需求改到崩溃。嗯,你品,你细品。