18.6 值对象
以属性值而非对象身份定义相等的小对象,通常保持不可变并承担相关校验。
学习目标
- 能把订单系统中的地址、邮箱等无独立身份概念建模为不可变值对象
- 能用值相等、构造校验、表示转换和原始类型执着比较值对象与裸字段
- 能在序列化、复制、无效输入和业务规则变化场景中验证值对象,并识别何时不应引入
为什么 18.6 值对象值得单独学习
↡以属性值而非数据库身份定义相等的小对象,通常不可变并集中保护自己的语义约束。值对象不是“字段换成 class”这么简单。两个独立创建的地址只要街道、城市和邮编相同,就可以互换;修改地址应产生新值,而不是让多个订单共享的对象悄悄变化。它适合没有独立生命周期、但需要稳定语义和校验的概念。
订单系统基础设施替换能暴露这个边界。EmailAddress 可以集中大小写、格式和序列化规则,数据库列、HTTP 请求和消息消费者都使用同一语义;但客户、订单和支付授权具有独立身份与生命周期,不能因为字段相似就改成值对象。
值相等让同一语义的不同实例可互换,也让缓存键、测试断言和序列化比较更直观。
↡创建后不直接改变值对象内部属性,任何变化都通过构造或转换产生新的有效值。不可变性减少别名共享和并发读写风险,但不能代替业务事务;它只保证一个值在自身生命周期内不会被偷偷改写。
先建立直觉:先确认语义,再决定封装
↡把外部字符串、数字或 JSON 转成经过校验的领域值,并在无效时拒绝构造的边界。校验边界应靠近值对象构造处,避免每个调用者重复判断格式;跨字段或跨聚合规则仍需由更高层的领域规则负责。
↡把所有业务概念都留作字符串或数字,导致格式、单位和合法性规则在调用处重复的设计倾向。值对象可以消除一部分原始类型执着,但不应把每个字段都包装成类。稳定语义、共享约束和错误成本,是引入的证据。
先预测:如果地址在三个订单、两个接口和一个消息中都以字符串传递,最容易发生的是格式分叉、空值语义分叉,还是数据库查询变慢?先列出可观察的重复规则和替换成本,再决定 PostalAddress 是否值得成为值对象。
目录单元到教学证据
18.6 值对象
在 18.6 值对象 的单元边界内,学习者要把订单系统基础设施替换拆成构造、比较、复制、序列化和变更五类证据。单元键为 poeaa24-pattern-46-value-object;核心证据是相同属性能值相等、无效组合不能构造、任何修改都不会影响已有订单,并且基础设施表示变化不会泄漏到业务调用方。
评审记录还要回答:这个概念是否有独立身份,哪些属性组成相等关系,校验失败如何报告,数据库与 API 如何转换,新增业务规则是否仍属于值自身。如果每个调用方都需要先清理字符串再创建对象,校验边界已经失效。
专属代码案例:地址值对象
下面的代码让地址在构造时成为有效值,并通过值相等和不可变更新表达语义。它不依赖 ORM 的实体身份,基础设施只需负责字符串列与值对象之间的转换。
type AddressInput = {
street: string;
city: string;
postalCode: string;
};
class PostalAddress {
readonly street: string;
readonly city: string;
readonly postalCode: string;
private constructor(input: AddressInput) {
this.street = input.street.trim();
this.city = input.city.trim();
this.postalCode = input.postalCode.trim().toUpperCase();
}
static create(input: AddressInput): PostalAddress {
if (
!input.street.trim() ||
!input.city.trim() ||
!/^\d{6}$/.test(input.postalCode.trim())
) {
throw new Error("invalid postal address");
}
return new PostalAddress(input);
}
equals(other: PostalAddress): boolean {
return (
this.street === other.street &&
this.city === other.city &&
this.postalCode === other.postalCode
);
}
withCity(city: string): PostalAddress {
return PostalAddress.create({
street: this.street,
city,
postalCode: this.postalCode,
});
}
}这段代码提供四项证据:构造边界拒绝无效邮编,内部属性只读,equals 比较属性而不是身份,withCity 返回新值。生产实现还需定义国际化格式、空值语义、数据库转换和错误分类;值对象不应把跨订单唯一性或权限判断塞进自身。
模式结构图
结构图把原始输入、校验边界、值对象和持久化表示分开,帮助评审确认基础设施转换不会破坏值语义。
值对象的变化边界
值对象内部承担字段组合、格式和相等规则;订单或聚合承担跨对象不变量;映射器承担数据库与 API 表示转换。边界清楚时,修改一个值不会悄悄影响其他引用。
选择与拒绝矩阵
| 评审问题 | 选择值对象的证据 | 应拒绝或暂缓的信号 |
|---|---|---|
| 身份 | 概念只由属性定义,没有独立生命周期 | 需要主键、审计历史或独立权限 |
| 相等 | Value Equality 的字段集合明确且可测试 | 调用者各自决定哪些字段相等 |
| 校验 | Validation Boundary 能拒绝无效组合 | 每个入口都先清理和判断字符串 |
| 变化 | Immutability 让更新产生新值,不影响旧引用 | 需要原地变更并协调共享状态 |
| 替代 | 已比较裸字段与实体对象,并保留撤回路径 | 只为“面向对象”或代码风格增加包装 |
常见误区
可验证练习
练习
本组练习围绕 18.6 值对象,要求把身份、相等、校验和变化边界写入同一张评审卡。
问题 1:判断是否引入。 邮箱在用户、订单通知和审计消息中都以字符串出现,格式规则重复。值对象的证据是什么?
问题 2:处理更新。 订单复制后把地址改为新城市,原订单和复制订单都引用同一个地址实例。应该原地修改吗?
问题 3:处理跨对象规则。 地址值对象需要知道客户是否欠款才能决定是否允许配送。这个判断应该放在哪里?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Value Object
以属性值定义相等、通常不可变且没有独立生命周期的小对象。
- Value Equality
只比较组成属性而不比较对象身份的相等规则。
- Immutability
创建后不直接改变内部值,变化通过构造或转换产生新值的特性。
- Validation Boundary
把外部表示转换为经过校验的领域值并拒绝无效输入的边界。
- Primitive Obsession
过度使用字符串或数字表达业务概念,导致语义和校验规则在调用处重复的倾向。
本章小结
掌握 18.6 值对象的标志,不是给每个字段增加一个类,而是能在订单系统基础设施替换中解释“外部表示 → 校验边界 → 值相等 → 不可变更新 → 持久化转换”的责任链。值对象适合没有独立身份、但有稳定语义和共享校验的概念;当规则跨对象、需要历史权限或必须原地协调状态时,应拒绝当前封装并选择实体、聚合或领域服务。
前后导航
来源与改写范围
- Martin Fowler 作者图书页:核对全书主题、教程与模式参考结构。
- Martin Fowler 模式目录:核对 18.6 值对象的公开模式名称和相关模式族。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。