Skip to content

实体与值对象:身份、相等与不可变 ​

判断一个对象是不是实体,不能只看表里有没有主键;判断是不是值对象,也不能只看它是不是 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 引用即可。

实体:Registration值对象:Moneyid = R-7phone 138…id = R-7phone 139…=id = R-8phone 138…id = R-9phone 138…≠100.00 CNY实例 A100.00 CNY实例 B=100.0record 包 BigDecimal100.00scale 不同≠标识相同即相等,属性可以变所有值相同即相等,构造后不再变
图 1 · 同一个报名改了手机号仍是同一个报名;两个金额对象只要数值和币种相同就可以互换。record 直接包 BigDecimal 时 100.0 与 100.00 不相等,构造时统一精度后才相等

三、实体:标识从哪里来 ​

实体的 equals 和 hashCode 只看标识:

java
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 比较,就会出现两个问题:

text
保存前 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,参数传反了编译器发现不了:

java
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 就能换来这个检查,是性价比最高的值对象之一。

四、值对象:构造即合法 ​

值对象的构造函数负责校验和规范化,之后它就不会再变:

java
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 包它:

text
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至少为 1new Capacity(0) 被拒绝
TimeSlot开始早于结束,提供 overlaps结束早于开始被拒绝

值对象把「字符串式编程」收拢到一处:没有它,手机号的格式判断会散落在控制器、导入脚本和短信模块里,每处的规则还略有不同。

+86 138-0013-800013800138000+861380013800012345Phone.parse去掉空格与短横线校验号段Phone(+8613800138000)IllegalArgumentExceptionHashSet 中只有 1 个
图 2 · 三种手机号写法经过构造函数后是同一个值;不合法的输入在构造时就失败,之后的代码不需要再校验

五、record 不等于值对象 ​

record 自动生成了按组件比较的 equals 和 hashCode,看起来天生就是值对象。但它只是浅不可变:

text
修改传入的列表并通过访问器追加后:Tags=[VIP, STAFF, PRESS],CopiedTags=[VIP]
向 CopiedTags 追加抛出 UnsupportedOperationException
两个内容都是 [1, 2] 的数组组件:equals=false
  • Tags(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 能省掉样板代码,但省不掉这些判断。下一篇讨论多个实体和值对象怎样组成一个 聚合,以及规则在并发修改下由谁守护。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权