实体与值对象(Entity / Value Object)
用途:判断一个对象该有 ID 还是该整体替换、字段该怎么建模时查阅
定义
「定义」小节链接| 构件 | 判定标准 | 相等性 | 可变性 |
|---|---|---|---|
| 实体(Entity) | 有连续标识与生命周期,跨状态变化仍是“同一个东西” | 按 ID 比较 | 可变(状态迁移) |
| 值对象(Value Object) | 只关心“是什么值”,无独立生命周期 | 按全部属性比较 | 不可变,修改即整体替换 |
一个可用的三问法:去掉 ID 还能区分两个对象吗?它会独立演化状态吗?业务方会关心“是不是同一个”而不是“值是否相同”吗? 三问都为“否” → 值对象。
例子:账户余额、金额、地址、时间段、经纬度、订单行(多数情况下)是值对象;账户、订单、合同、用户是实体。
由来与要解决的问题
「由来与要解决的问题」小节链接把一切都建成带 ID 的实体,会带来三类成本:状态可被局部修改因而不变式难守护;相等性判断依赖 ID 而忽略内容差异;对象图庞大、加载与比较昂贵。
值对象用不可变 + 整体替换换取三个好处:天然线程安全、可直接作为参数与返回值传递、相等性可预期因而便于缓存与去重。
关键要点
「关键要点」小节链接- 值对象自带校验:构造即合法。不允许出现
Money对象但币种为空——校验放在构造/工厂里,而不是散在调用方。 - 修改返回新实例:
money.add(other)返回新对象,不改变原对象。 - 值对象可以嵌套,但要保持整体语义(地址里含省市区与邮编)。
- 实体标识要稳定且业务无关:用代理键或 UUID,不要用会变的业务字段(如手机号、证件号)当标识。
- 集合类成员归聚合管:值对象集合的增删必须通过聚合根方法,避免外部持有引用绕过不变式。
- 不要为了“DDD 完整”而把纯数据结构硬造成值对象:只有当“整体替换 + 内容相等”确实符合业务语义时才值得。
常见实现形态
「常见实现形态」小节链接| 形态 | 做法 | 代价 |
|---|---|---|
| 嵌入列 | 值对象字段展开成所属表的多个列 | 表变宽;查询某属性方便 |
| JSON 列 | 整体序列化存一列 | 无法按内部字段检索;需要 Schema 版本管理 |
| 独立表 | 值对象存表但只通过根访问 | 多一次关联;对“值”语义不友好 |
| 语言级不可变 | Java record、Kotlin data class、final 字段 |
需要构造期校验配套 |
| 基础类型封装 | 用 Money 取代 BigDecimal,把币种约束进类型 |
初期样板代码多 |
开源参考:Joda-Money、javax.money(Moneta)示范金额值对象;Java record 与 Avaje / MapStruct 支撑不可变建模与转换;Hibernate 的 @Embeddable 处理嵌入形态。
ORM 带来的摩擦
「ORM 带来的摩擦」小节链接- 相等性:实体按 ID 比较时,同一聚合在两个会话中可能是不同实例,需要正确实现
equals/hashCode(用 ID,且处理未持久化时 ID 为空的情况)。 - 集合语义:ORM 的持久化集合会延迟加载并可能被外部直接修改,破坏聚合封装。常见做法是内部持有集合、对外返回不可变副本。
- 值对象变更检测:不可变对象整体替换天然可检测;若实现了可变值对象,ORM 可能漏掉变更。
与其他概念的关系
「与其他概念的关系」小节链接- 聚合:聚合由实体与值对象构成,值对象常是不变式的载体(如状态、金额、有效期)。
- 资源库:仓储以聚合为单位存取,值对象随聚合根一起持久化。
- DTO:DTO 是为传输而生的可变数据袋,跨层传递后丢弃;值对象是模型的一部分,携带语义与校验。
常见误区
「常见误区」小节链接| 误区 | 表现 |
|---|---|
| 一切皆实体 | 每个概念都有表和 ID,模型退化成数据库结构镜像 |
| 可变值对象 | 名字叫值对象,却有 setter,相等性判断随之失真 |
| 值对象无校验 | 构造后处于非法状态,规则又回到服务层 |
| 用业务字段做标识 | 字段变更等于实体身份变更,历史数据无法关联 |
| 贫血实体 | 实体只承载字段,迁移与校验散落在服务里 |
延伸阅读
「延伸阅读」小节链接- Eric Evans《领域驱动设计》第 5 章(实体与值对象)
- Vaughn Vernon《实现领域驱动设计》第 3 章(值对象的建模规则)
- Martin Fowler《企业应用架构模式》第 4 章(Domain Object、Serialized Object 等)