跳转到内容

实体与值对象(Entity / Value Object)

用途:判断一个对象该有 ID 还是该整体替换、字段该怎么建模时查阅

构件 判定标准 相等性 可变性
实体(Entity) 有连续标识与生命周期,跨状态变化仍是“同一个东西” 按 ID 比较 可变(状态迁移)
值对象(Value Object) 只关心“是什么值”,无独立生命周期 按全部属性比较 不可变,修改即整体替换

一个可用的三问法:去掉 ID 还能区分两个对象吗?它会独立演化状态吗?业务方会关心“是不是同一个”而不是“值是否相同”吗? 三问都为“否” → 值对象。

例子:账户余额、金额、地址、时间段、经纬度、订单行(多数情况下)是值对象;账户、订单、合同、用户是实体。

把一切都建成带 ID 的实体,会带来三类成本:状态可被局部修改因而不变式难守护;相等性判断依赖 ID 而忽略内容差异;对象图庞大、加载与比较昂贵。

值对象用不可变 + 整体替换换取三个好处:天然线程安全、可直接作为参数与返回值传递、相等性可预期因而便于缓存与去重。

  1. 值对象自带校验:构造即合法。不允许出现 Money 对象但币种为空——校验放在构造/工厂里,而不是散在调用方。
  2. 修改返回新实例:money.add(other) 返回新对象,不改变原对象。
  3. 值对象可以嵌套,但要保持整体语义(地址里含省市区与邮编)。
  4. 实体标识要稳定且业务无关:用代理键或 UUID,不要用会变的业务字段(如手机号、证件号)当标识。
  5. 集合类成员归聚合管:值对象集合的增删必须通过聚合根方法,避免外部持有引用绕过不变式。
  6. 不要为了“DDD 完整”而把纯数据结构硬造成值对象:只有当“整体替换 + 内容相等”确实符合业务语义时才值得。
形态 做法 代价
嵌入列 值对象字段展开成所属表的多个列 表变宽;查询某属性方便
JSON 列 整体序列化存一列 无法按内部字段检索;需要 Schema 版本管理
独立表 值对象存表但只通过根访问 多一次关联;对“值”语义不友好
语言级不可变 Java record、Kotlin data class、final 字段 需要构造期校验配套
基础类型封装 用 Money 取代 BigDecimal,把币种约束进类型 初期样板代码多

开源参考:Joda-Money、javax.money(Moneta)示范金额值对象;Java record 与 Avaje / MapStruct 支撑不可变建模与转换;Hibernate 的 @Embeddable 处理嵌入形态。

  • 相等性:实体按 ID 比较时,同一聚合在两个会话中可能是不同实例,需要正确实现 equals/hashCode(用 ID,且处理未持久化时 ID 为空的情况)。
  • 集合语义:ORM 的持久化集合会延迟加载并可能被外部直接修改,破坏聚合封装。常见做法是内部持有集合、对外返回不可变副本。
  • 值对象变更检测:不可变对象整体替换天然可检测;若实现了可变值对象,ORM 可能漏掉变更。
  • 聚合:聚合由实体与值对象构成,值对象常是不变式的载体(如状态、金额、有效期)。
  • 资源库:仓储以聚合为单位存取,值对象随聚合根一起持久化。
  • DTO:DTO 是为传输而生的可变数据袋,跨层传递后丢弃;值对象是模型的一部分,携带语义与校验。
误区 表现
一切皆实体 每个概念都有表和 ID,模型退化成数据库结构镜像
可变值对象 名字叫值对象,却有 setter,相等性判断随之失真
值对象无校验 构造后处于非法状态,规则又回到服务层
用业务字段做标识 字段变更等于实体身份变更,历史数据无法关联
贫血实体 实体只承载字段,迁移与校验散落在服务里
  • Eric Evans《领域驱动设计》第 5 章(实体与值对象)
  • Vaughn Vernon《实现领域驱动设计》第 3 章(值对象的建模规则)
  • Martin Fowler《企业应用架构模式》第 4 章(Domain Object、Serialized Object 等)