本节摘要:实体(Entity)与值对象(Value Object)是战术设计的两种基本砖块,区分它们只需要一个问题:两个对象相等,是"同一个东西"还是"长得一样"?实体靠唯一身份在时间中延续,值对象靠属性整体等价、生来不可变。本节给出可操作的判定流程、完整的代码对照,以及"值对象优先"这条被低估的纪律——它省下的不只是代码量,更是成片的判断重复。
第 2 章把地皮划好了,战术设计从最基础的选砖开始。判定砖块材质,先放下所有术语,只看两个问题的区别。问"这是谁"——北京那套房子不管装修翻新多少次,房产证上的编号不变,它靠身份回答"是谁";问"这是什么样"——一百块钱在你口袋里还是银行里、纸面新旧如何,它都等值,它靠属性回答"是什么样"。前者的建模材质是实体,后者是值对象。
对应到业务:订单是实体——订单改了收货地址、换了商品行,它仍是同一笔订单,因为订单号不变;金额是值对象——一百元的应付金额与另一处的一百元应付金额无需区分彼此,比较属性就够了。这个区分不是语法游戏,它直接决定代码里相等怎么判、状态能不能改、测试怎么写。
| 维度 | 实体 | 值对象 |
|---|---|---|
| 相等的含义 | 身份相同(id 相等) | 属性整体等价 |
| 可变性 | 生命周期内可变,变更有方法约束 | 不可变,"修改"是换成新实例 |
| 生命周期 | 有创建到关闭的完整轨迹 | 随宿主存在,无独立轨迹 |
| 典型例子 | 订单、用户、承运单 | 金额、地址、日期区间、优惠构成 |
| 测试方式 | 沿状态迁移断言 | 给定输入断言输出,纯函数式 |
值对象不可变带来三个连锁红利。第一,判断只写一次:"这笔金额是否满足门槛"写进 Money 类,全系统所有用到金额的地方自动共享同一个判断——1.4 实验里"规则副本四散"的病灶,值对象就是最便宜的药。第二,天然线程安全:不可变对象没有并发修改的问题,跨上下文传值不用防御性拷贝。第三,回收干净:没有别名引用,改就是换新,不会出现"改了 A 处 B 处也变了"的灵异现象。

以交易上下文里"订单行"与"金额"为例,看两种砖各自的写法纪律。
// 值对象:金额——不可变,行为内聚,判等看属性 public final class Money { private final BigDecimal amount; // 不可变:final 且不暴露 setter private final Currency currency; private Money(BigDecimal amount, Currency currency) { if (amount.scale() > 2) throw new IllegalArgumentException("金额最多两位小数"); this.amount = amount; this.currency = currency; } public static Money of(long yuan) { return new Money(BigDecimal.valueOf(yuan), CNY); } public Money plus(Money other) { checkSameCurrency(other); return new Money(amount.add(other.amount), currency); // "修改"即新建 } public boolean meets(Money threshold) { // 门槛判断写一次,全系统共享 checkSameCurrency(threshold); return amount.compareTo(threshold.amount) >= 0; } @Override public boolean equals(Object o) { // 判等看属性整体 if (!(o instanceof Money)) return false; Money m = (Money) o; return amount.compareTo(m.amount) == 0 && currency.equals(m.currency); } @Override public int hashCode() { return amount.stripTrailingZeros().hashCode(); } }
// 实体:订单行——有身份,状态迁移走业务方法 public class OrderLine { private final OrderLineId id; // 身份:子订单号,跨变更保持 private SkuId skuId; // 可变:支持换货 private int quantity; private Money unitPrice; OrderLine(OrderLineId id, SkuId skuId, int quantity, Money unitPrice) { if (quantity <= 0) throw new IllegalArgumentException("数量必须为正"); this.id = id; this.skuId = skuId; this.quantity = quantity; this.unitPrice = unitPrice; } void changeSku(SkuId newSku, Money newUnitPrice) { // 换货:身份不变,描述在变 this.skuId = newSku; this.unitPrice = newUnitPrice; } Money lineTotal() { return unitPrice.multipliedBy(quantity); } @Override public boolean equals(Object o) { // 判等只看身份 return o instanceof OrderLine && ((OrderLine) o).id.equals(this.id); } }
两段代码放在一起读,注意三处刻意的差异。判等:Money 比属性,OrderLine 只比 id——换货后的订单行仍是"那一行",判据是身份而不是内容。可变性:Money 连构造器都是私有的、不提供任何 setter;OrderLine 的字段可变,但变更必须走业务方法(changeSku),方法里守着约束。行为归属:门槛判断长在 Money 上,因为"满不满门槛"是金额和门槛这两个值之间的关系;数量校验长在 OrderLine 上,因为"数量为正"是这一行的自身不变量。行为跟着不变量走,这就是选砖的全部逻辑。
⚠️ 两个高频翻车点。其一,用 JPA 等对象关系映射工具时,实体默认要求可变与无参构造,与值对象天然冲突——别为了映射方便把值对象改成可变的,正确做法是把值对象映射成嵌入对象(各框架都有对应机制)。其二,把数据库表结构当建模依据:"订单行表有 id,所以订单行必须是实体"——表主键是存储细节,判定只看业务语义:如果业务从不需要区分"两行内容完全一样的订单行",它在模型里就该是值对象,表里那个 id 留给存储层自己用。
为什么反复强调值对象优先?账很容易算。把一个概念建成实体,你签下的是一整套生命周期合同:判等要定 id、存储要建表、引用要考虑生命周期、并发要考虑锁。建成值对象,合同只有两行:属性判等、不可变。青柚商城动工第一月有过一次返工教训:最初把"收货地址"建成实体(因为数据库里有地址表),后来发现业务从不区分"两个一模一样的地址",地址的每次修改其实就是"换一个地址"——改回值对象后,顺带删掉了地址去重、地址同步两块代码。反过来的教训更贵:把"优惠券"建成值对象("不就是一组属性嘛"),三个月后发现业务要追溯"这张券被谁用掉、什么时候核销"——券需要轨迹,它是实体。误降级的代价是补生命周期,误升级的代价是养一块没人用的身份机制,两笔账都比最初选对贵。
两种砖备齐,砌墙时马上会遇到装不下的逻辑:跨两个聚合的判断该放哪?复杂构造怎么重建不变量?这是 3.2 领域服务与工厂的正题。