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);
}
}
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);
}
}
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.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());
}
}
3.5 四个概念的关系
这四个概念不是孤立的。它们共同构成了领域模型的骨架:
| 概念 | 核心特征 | 典型例子 | 我的经验 |
|---|---|---|---|
| 实体 | 有唯一标识,可变 | 用户、订单、商品 | ID 用值对象封装 |
| 值对象 | 无标识,不可变 | 地址、金额、颜色 | 优先用值对象,减少实体数量 |
| 聚合 | 一致性边界,聚合根 | 订单+订单项 | 别太大,也别太小 |
| 领域事件 | 事实记录,解耦 | OrderPaidEvent | 先定义事件,再选技术 |
说白了,实体和值对象是「积木」,聚合是「积木拼成的模块」,领域事件是「模块之间的信号」。你把这四样东西搞清楚了,微服务的边界划分就有了理论依据。