实体与值对象:身份、相等与不可变
判断一个对象是不是实体,不能只看表里有没有主键;判断是不是值对象,也不能只看它是不是
record。真正要问的是:业务怎样判断「还是不是同一个东西」。
一次报名改了联系电话,它还是原来那次报名;一笔 100 元的应收换成另一个「100 元」对象,业务不关心是哪一个实例。前者是实体(Entity),按身份比较;后者是值对象(Value Object),按值比较。这个区分听起来简单,写成 Java 以后却有不少坑:BigDecimal 的 equals 比较精度,record 只是浅不可变,保存前没有 id 的实体放进 HashSet 会互相覆盖。
本文用活动报名案例里的 Registration、Money、Phone、Capacity 和 TimeSlot 逐一验证这些行为,所有结果在 JDK 21 上实测。
一、先说结论
- 实体按标识相等,值对象按值相等:标识不变,属性改了仍是同一个实体;两个值对象的所有值相同,就可以互相替换。
- 标识在创建时就确定:依赖数据库在保存时分配 id,保存前两个实体都是
null,实测放进HashSet后只剩 1 个。 - 值对象在构造时就合法:非法手机号、容量 0、结束早于开始的时间段,都应该在构造函数里失败,而不是等到某次计算时才出错。
- 值对象要规范化:record 直接包
BigDecimal时,100.0与100.00不相等;构造时统一精度之后才相等。 - record 不等于值对象:record 保证字段不被重新赋值,不保证字段指向的列表不被修改;数组组件还会按引用比较。
- 不是每个字段都值得包装:只有携带规则、单位或容易混淆的语义时,才值得变成值对象。
二、业务怎样判断「同一个」
| 对象 | 业务怎么判断是不是同一个 | 类型 |
|---|---|---|
| 报名 | 报名编号相同就是同一次报名,改了电话、改了状态也是 | 实体 |
| 场次 | 场次编号相同就是同一个场次,容量可以调整 | 实体 |
| 金额 | 金额和币种都相同就是同一个值 | 值对象 |
| 手机号 | 规范化以后号码相同就是同一个值 | 值对象 |
| 时间段 | 开始和结束都相同就是同一个值 | 值对象 |
| 参会人 | 在报名上下文里只按编号引用;在会员中心里是一个有生命周期的实体 | 取决于上下文 |
最后一行说明,实体还是值对象不是对象本身的属性,而是某个上下文里的建模决定。同一个人,在会员中心有资料修改历史,是实体;在报名上下文里只需要一个编号,用值对象 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。
3.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 个。代价是标识不再是紧凑的自增整数,主键选择对索引的影响见 订单、库存与数据一致性。
3.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 | 结束早于开始被拒绝 |
值对象把「字符串式编程」收拢到一处:没有它,手机号的格式判断会散落在控制器、导入脚本和短信模块里,每处的规则还略有不同。
五、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 和普通类。
八、常见误区
- 「有主键就是实体」:数据库为每行分配主键,不代表业务关心它的身份。一张存金额明细的表有主键,里面的金额仍然是值。
- 「record 就是值对象」:record 是浅不可变;列表需要复制,数组需要自己处理相等性,校验和规范化也要自己写。
- 「值对象要有 setter 方便修改」:修改值对象就是换一个新值,
plus、withXxx返回新实例。 - 「实体的 id 让数据库生成就好」:保存前没有身份的实体在集合、缓存、事件里都会出问题;能在创建时确定就在创建时确定。
- 「所有字段都包成值对象才算 DDD」:只包那些有规则、有单位、容易混淆的字段。
小结
实体和值对象的区分,来自业务判断「同一个」的方式:按身份还是按值。落到 Java 里,实体要在创建时拿到稳定的类型化标识,equals 只看标识;值对象要在构造时校验和规范化,之后不再改变。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