实体与值对象:身份、相等与不可变
判断一个对象是不是实体,不能只看表里有没有主键;判断是不是值对象,也不能只看它是不是
record。真正要问的是:业务怎样判断「还是不是同一个东西」。
一次报名改了联系电话,它还是原来那次报名;一笔 100 元的应收换成另一个「100 元」对象,业务不关心是哪一个实例。前者是实体(Entity),按身份比较;后者是值对象(Value Object),按值比较。这个区分听起来简单,写成 Java 以后却有不少坑:BigDecimal 的 equals 比较精度,record 只是浅不可变,保存前没有 id 的实体放进 HashSet 会互相覆盖。
本文用活动报名案例里的 Registration、Money、Phone、Capacity 和 TimeSlot 逐一验证这些行为,所有结果在 JDK 21 上实测。
一、业务怎样判断「同一个」
| 对象 | 业务怎么判断是不是同一个 | 类型 |
|---|---|---|
| 报名 | 报名编号相同就是同一次报名,改了电话、改了状态也是 | 实体 |
| 场次 | 场次编号相同就是同一个场次,容量可以调整 | 实体 |
| 金额 | 金额和币种都相同就是同一个值 | 值对象 |
| 手机号 | 规范化以后号码相同就是同一个值 | 值对象 |
| 时间段 | 开始和结束都相同就是同一个值 | 值对象 |
| 参会人 | 在报名上下文里只按编号引用;在会员中心里是一个有生命周期的实体 | 取决于上下文 |
最后一行说明,实体还是值对象不是对象本身的属性,而是某个上下文里的建模决定。同一个人,在会员中心有资料修改历史,是实体;在报名上下文里只需要一个编号,用值对象 AttendeeId 引用即可。数据库主键也不能替这个决定:数据库为每行分配主键,不代表业务关心它的身份,一张存金额明细的表有主键,里面的金额仍然是值。
二、实体:标识从哪里来
实体的 equals 和 hashCode 只看标识:
public final class Registration {
private final RegistrationId id; // 创建时生成,之后不变
private final SessionId sessionId;
private final AttendeeId attendeeId;
private Phone contact; // 可以修改
@Override
public boolean equals(Object o) {
return o instanceof Registration other && id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}实测:同一标识、手机号不同,equals 为 true;标识不同、其他属性完全相同,equals 为 false。
2.1 保存时才分配标识的问题
很多项目的实体 id 是数据库自增主键,保存之前为 null。如果 equals 按 id 比较,就会出现两个问题:
保存前 id 都为 null:两个报名放进 HashSet 后剩 1 个(alice)
放进 HashSet 后再分配 id=42:contains=false第一行是因为 Objects.equals(null, null) 为 true,bob 被当作重复元素丢掉了;第二行是因为分配 id 之后 hashCode 变了,对象还在集合里,但按新的哈希值找不到它。
解决方式是在创建时就生成标识:UUID、雪花算法或者先从序列取号,都能让实体从诞生起就有稳定的身份。改为创建时生成 RegistrationId 之后,两个报名放进 HashSet 后是 2 个。代价是标识不再是紧凑的自增整数,主键选择对索引的影响见 订单、库存与数据一致性。
2.2 用类型区分不同的标识
标识如果都是 String 或 Long,参数传反了编译器发现不了:
void register(SessionId session, AttendeeId attendee) { ... }
register(attendee, session);
// 编译错误:incompatible types: AttendeeId cannot be converted to SessionId
void register(String sessionId, String attendeeId) { ... }
register(attendeeId, sessionId); // 编译通过,运行时报名到一个不存在的场次实测用 javax.tools.JavaCompiler 编译这两段代码:类型化标识得到 1 个编译错误,String 标识 0 个。一个只包一个字段的 record 就能换来这个检查,是性价比最高的值对象之一。
三、值对象:构造即合法
值对象的构造函数负责校验和规范化,之后它就不会再变:
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.signum() < 0) {
throw new IllegalArgumentException("金额不能为负:" + amount);
}
try {
// 统一到币种的小数位:人民币是 2 位
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
} catch (ArithmeticException e) {
throw new IllegalArgumentException(currency + " 最多 " + currency.getDefaultFractionDigits() + " 位小数:" + amount);
}
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("币种不同不能相加:" + currency + " + " + other.currency);
}
return new Money(amount.add(other.amount), currency); // 返回新值
}
}setScale 这一行不是装饰。BigDecimal.equals 同时比较数值和精度(scale),直接用 record 包它:
record 直接包 BigDecimal:100.0 与 100.00 equals=false,compareTo=0,放进 HashSet 后 2 个
Money 规范化后:1 个同一笔钱在集合里算成两笔,是对账出错的典型来源。规范化之后,Money.of("100", "CNY") 与 Money.of("100.00", "CNY") 相等;plus 返回新值,原来的 Money 仍是 100.00;币种不同相加、人民币超过 2 位小数都在构造或运算时被拒绝。
同样的思路用于其他值对象:
| 值对象 | 构造时做什么 | 实测 |
|---|---|---|
Phone | 去掉空格和短横线,统一为 +86 开头的形式;格式不对就拒绝 | +86 138-0013-8000、13800138000、+8613800138000 解析后是 1 个值;12345 被拒绝 |
Capacity | 至少为 1 | new Capacity(0) 被拒绝 |
TimeSlot | 开始早于结束,提供 overlaps | 结束早于开始被拒绝 |
值对象把「字符串式编程」收拢到一处:没有它,手机号的格式判断会散落在控制器、导入脚本和短信模块里,每处的规则还略有不同。
时间段也是一个典型的值对象:它守住「开始早于结束」,并给出「重叠」的唯一定义;但「同一个人的班次两两不重叠」是跨多个值的规则,守在哪里见「同一时段不能重叠」守在哪里。
区间值对象的三个细节。 时间段、价格区间、版本范围这类值对象,在构造时校验「开始早于结束」之外,还有三件事要在模型里说清:
- 端点语义:统一用半开区间
[start, end),相邻的两段首尾相接、没有缝隙也没有重叠。闭区间在拼接、拆分时都要处理「端点属于哪一段」。 - 合并不等于并集:
[1, 3)与[5, 7)的并集不是一个区间。「合并」方法只能用于相交或相邻的区间,否则要返回一组区间,或者直接拒绝。把并集硬塞成[1, 7)会悄悄把中间的空档算进去。 - 共享值的清理:值对象可以被多个实体共享引用(同一个地址被多个订单引用)。如果它被单独存储、按引用共享,就要有人负责在没有实体引用它时清理它;更常见、也更简单的做法是不共享存储,每个实体保存自己的一份值。
值对象也不需要 setter:修改值对象就是换一个新值,plus、withXxx 这类方法返回新实例,原来的值不受影响。
四、record 不等于值对象
record 自动生成了按组件比较的 equals 和 hashCode,看起来天生就是值对象。但它只是浅不可变:
修改传入的列表并通过访问器追加后:Tags=[VIP, STAFF, PRESS],CopiedTags=[VIP]
向 CopiedTags 追加抛出 UnsupportedOperationException
两个内容都是 [1, 2] 的数组组件:equals=falseTags(List<String> values)直接保存了调用方的列表,调用方之后的修改、以及任何人通过values().add(...)的修改,都会改变这个「不可变」的值;- 在紧凑构造函数里
values = List.copyOf(values),得到的是不可修改的副本; - 数组组件的
equals比较引用,内容相同的两个 record 不相等,需要自己重写,或者改用List。
更多关于 record 与不可变性的讨论,见 Java 里的函数式设计。
五、什么时候不值得包装
值对象有成本:更多的类型、更多的转换代码,序列化和持久化都要多写映射。下面几种情况值得包装:
- 有校验规则:手机号、邮箱、容量;
- 有单位或精度:金额、时长、重量;
- 容易混淆:各种标识、开始时间与结束时间;
- 有行为:
Money.plus、TimeSlot.overlaps。
一个只用于展示、没有规则的备注字段,包成 Remark 值对象只会增加噪声。
六、与 Domain Driven Kit 的对照
Domain Driven Kit 提供了 Identifier、ValueObject、Entity 等基类,把本文的约定写成了可复用的类型:标识是强类型,实体按标识相等,值对象按值相等。这是一个实现选择,不是 DDD 的前提;本文的示例都不依赖任何框架,只用 JDK 21 的 record 和普通类。
下一篇讨论多个实体和值对象怎样组成一个 聚合,以及规则在并发修改下由谁守护。
值对象的校验只在经过构造器时有效;反序列化、反射与重建等其他入口怎样绕过它,见构造器里写了校验,对象就一定合法吗。
配套实验
- codesphere-labs/ddd/entities-and-value-objects:实体与值对象的相等性、
BigDecimal的精度、构造即校验、record 的浅不可变、保存时才分配标识、类型化标识的编译检查(验证记录)
参考资料
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 5 章(实体、值对象)
- Martin Fowler,ValueObject
- JLS 21 §8.10 Record Classes
- JDK 21 API:BigDecimal.equals