上一篇文章中,我们通过事件风暴梳理了业务事件、命令和业务规则,也得到了一些领域模型的线索。
从这一篇开始,我们进入 DDD 的战术设计。先从两个最基础、也最容易混淆的概念说起:实体和值对象。
不少人第一次接触它们时,会直接按照数据库表来判断:有表、有主键的就是实体,没有单独建表的就是值对象。这个判断方式很省事,但也很容易把模型带偏。
实体和值对象的区别,不在于怎么存储,而在于业务如何判断"它还是不是原来的那个对象"。
一、什么是实体
实体是通过唯一标识区分,并且会在业务过程中持续变化的领域对象。
以订单为例。一笔订单从待支付变成已支付,再变成已完成,状态和内容都可能发生变化,但只要订单编号没有变,业务上就认为它还是同一笔订单。
所以实体通常有两个特点:
- 有能够区分身份的唯一标识;
- 有自己的生命周期,属性会随着业务行为发生变化。
需要注意,唯一标识不一定就是数据库自增主键。订单号、用户编号、设备编码,只要能在当前业务范围内稳定识别对象,都可以成为实体标识。
下面是一个简化的订单实体:
java
/**
* 订单实体
*
* 订单通过订单编号识别身份,支付状态会随着业务流程发生变化。
*/
public class Order {
/** 订单编号,用于在业务范围内唯一识别一笔订单 */
private final String orderNo;
/** 订单当前状态 */
private OrderStatus status;
/**
* 支付订单
*
* 只有待支付订单才能执行支付,状态变更必须通过业务方法完成,
* 避免外部代码绕过业务规则直接修改订单状态。
*/
public void pay() {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new IllegalStateException("当前订单状态不允许支付");
}
this.status = OrderStatus.PAID;
}
}
这里真正重要的不是 Order 有没有对应一张表,而是订单编号和支付状态在业务中的含义。
二、什么是值对象
值对象没有独立身份,它通过一组属性共同表达业务含义。
例如金额不能只用一个 BigDecimal 表示,因为 100 元和 100 美元显然不是一回事。金额和币种组合在一起,才是一个完整的业务概念。
值对象通常有下面几个特点:
- 没有需要持续跟踪的唯一标识;
- 属性相同,业务上就认为两个对象相等;
- 创建后尽量保持不变,需要修改时创建新对象整体替换;
- 可以在创建时完成格式、范围等规则校验。
下面定义一个金额值对象:
java
/**
* 金额值对象
*
* 金额必须和币种一起表达业务含义,对象创建后不可修改。
*/
public record Money(BigDecimal amount, Currency currency) {
/**
* 创建金额对象时统一校验基础规则,
* 防止非法金额进入后续订单计算流程。
*/
public Money {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("金额不能为空且不能小于零");
}
if (currency == null) {
throw new IllegalArgumentException("币种不能为空");
}
}
/**
* 金额相加不会修改当前对象,而是返回一个新的金额对象。
*/
public Money add(Money other) {
if (!currency.equals(other.currency())) {
throw new IllegalArgumentException("不同币种不能直接相加");
}
return new Money(amount.add(other.amount()), currency);
}
}
值对象并不等于"只放数据"。像金额相加、日期范围校验、手机号格式校验这类只依赖自身属性的业务规则,都可以放进值对象。
三、实体和值对象怎么判断
两者的核心区别可以先看这张表:
| 判断维度 | 实体 | 值对象 |
|---|---|---|
| 如何区分 | 看唯一标识 | 看全部关键属性 |
| 是否有生命周期 | 有,需要持续跟踪 | 没有独立生命周期 |
| 属性变化 | 身份不变,属性可以变化 | 通常创建新对象整体替换 |
| 相等判断 | 标识相同即可认为是同一对象 | 属性相同才认为相等 |
| 常见例子 | 订单、用户、合同 | 金额、地址、时间范围 |
实际建模时,可以连续问三个问题。
1. 业务是否关心它是谁
两个对象的属性完全相同,业务还需要区分它们吗?
比如两张同名、同价的订单,仍然是两笔不同订单,因此订单是实体。两个金额和币种都相同的金额对象,业务上通常不需要区分来源,因此金额适合作为值对象。
2. 是否需要跟踪它的变化
如果对象会经历创建、修改、失效等过程,并且业务需要知道变化前后仍是同一个对象,它更像实体。
如果业务只关心当前这组属性,不关心对象本身经历过什么,它更像值对象。
3. 修改时是更新还是替换
订单支付后,我们会更新这笔订单的状态,因为它仍是原来的订单。
收货地址发生变化时,更合适的做法是创建一个新地址,整体替换旧地址,而不是先改省份、再改城市。这样可以避免业务过程中出现"省份已经更新,城市还是旧值"的临时错误状态。
如果三个问题仍然得不出答案,就回到真实业务场景继续确认,不要根据类名或表结构硬猜。
四、同一个概念不一定永远是同一种模型
实体和值对象不是固定标签,同一个概念放在不同业务中,建模结果可能完全不同。
例如地址:
- 在订单中,业务只关心下单时的省、市、区和详细地址,地址适合作为值对象;
- 在地址簿中,用户可以新增、编辑、删除某一条地址,还要设置默认地址,此时每条地址需要被单独识别,更适合作为实体。
再比如商品:
- 在商品中心,商品有独立编号和上下架生命周期,是实体;
- 在历史订单中,商品名称、成交价等信息需要保留下单时的快照,这部分更像值对象。
所以不能背"地址就是值对象,商品就是实体"。模型的类型取决于当前限界上下文中的业务含义。
五、几个常见误区
1. 有数据库主键就是实体
值对象为了方便存储,也可能单独建表并带有技术主键。这个主键只是数据库实现细节,不代表业务需要通过它识别对象。
反过来,一个业务实体暂时没有落库,也不妨碍它成为实体。
2. 值对象必须只有一个字段
值对象可以包含多个字段。省、市、区、详细地址组合成地址,开始时间和结束时间组合成时间范围,都比散落的基础类型更能表达业务含义。
3. 值对象绝对不能变化
更准确地说,值对象自身不应该被逐项修改,但持有它的实体可以用新值对象替换旧值对象。
订单可以更换收货地址,但应该替换整个地址对象,而不是把地址对象暴露出去随意调用 setter。
4. Java 中叫 Entity 的类就是 DDD 实体
很多项目会把数据库映射类命名为 XxxEntity,它可能只是一个贫血的数据对象。DDD 实体强调的是业务身份和生命周期,两者不能只靠类名判断。
至于 DTO、VO、DO 和 Entity 在代码中怎么区分,后面的代码落地章节再单独整理,这里先不展开。
六、总结
实体和值对象看起来只是两个名词,实际决定的是我们如何表达业务。
- 实体看身份,即使属性变化,仍然可以通过唯一标识持续跟踪;
- 值对象看属性,属性相同就表示相同的业务含义;
- 值对象尽量保持不可变,变化时创建新对象整体替换;
- 不要用数据库主键、是否建表或者类名代替业务判断;
- 同一个概念在不同限界上下文中,可能有不同的建模结果。
下一篇文章我们继续学习聚合和聚合根,看看实体和值对象应该如何组织在一起,以及一致性边界到底应该怎么划分。