18.6 值对象

以属性值而非对象身份定义相等的小对象,通常保持不可变并承担相关校验。

学习目标

  • 能把订单系统中的地址、邮箱等无独立身份概念建模为不可变值对象
  • 能用值相等、构造校验、表示转换和原始类型执着比较值对象与裸字段
  • 能在序列化、复制、无效输入和业务规则变化场景中验证值对象,并识别何时不应引入

为什么 18.6 值对象值得单独学习

值对象不是“字段换成 class”这么简单。两个独立创建的地址只要街道、城市和邮编相同,就可以互换;修改地址应产生新值,而不是让多个订单共享的对象悄悄变化。它适合没有独立生命周期、但需要稳定语义和校验的概念。

订单系统基础设施替换能暴露这个边界。EmailAddress 可以集中大小写、格式和序列化规则,数据库列、HTTP 请求和消息消费者都使用同一语义;但客户、订单和支付授权具有独立身份与生命周期,不能因为字段相似就改成值对象。

值相等让同一语义的不同实例可互换,也让缓存键、测试断言和序列化比较更直观。

不可变性减少别名共享和并发读写风险,但不能代替业务事务;它只保证一个值在自身生命周期内不会被偷偷改写。

先建立直觉:先确认语义,再决定封装

校验边界应靠近值对象构造处,避免每个调用者重复判断格式;跨字段或跨聚合规则仍需由更高层的领域规则负责。

值对象可以消除一部分原始类型执着,但不应把每个字段都包装成类。稳定语义、共享约束和错误成本,是引入的证据。

先预测:如果地址在三个订单、两个接口和一个消息中都以字符串传递,最容易发生的是格式分叉、空值语义分叉,还是数据库查询变慢?先列出可观察的重复规则和替换成本,再决定 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 返回新值。生产实现还需定义国际化格式、空值语义、数据库转换和错误分类;值对象不应把跨订单唯一性或权限判断塞进自身。

模式结构图

18.6 值对象:稳定语义位于表示与聚合之间外部表示字符串 / JSON用户输入Validation Boundary规范化 / 拒绝无效Value EqualityImmutability订单聚合持有值跨对象规则映射器DB / API表示转换值对象没有独立身份;变化产生新值,不修改旧引用让语义约束离开裸字段,仍不越过聚合边界
值对象集中值相等、校验和不可变语义,订单聚合负责跨对象决定,映射器负责表示转换。

结构图把原始输入、校验边界、值对象和持久化表示分开,帮助评审确认基础设施转换不会破坏值语义。

值对象的变化边界

18.6 值对象:自身约束与外部协作分层值对象自身字段格式 / 组合合法性值相等 / 新值更新订单聚合跨对象不变量客户 / 配送 / 库存决定是否允许变化基础设施转换DB / API / 消息读取与序列化只读旧值 → 构造新值;跨对象规则不下沉值对象越小越要准确,不是越多越好
值对象保护自己的值语义,聚合保护跨对象规则,基础设施只负责表示转换。

值对象内部承担字段组合、格式和相等规则;订单或聚合承担跨对象不变量;映射器承担数据库与 API 表示转换。边界清楚时,修改一个值不会悄悄影响其他引用。

选择与拒绝矩阵

评审问题选择值对象的证据应拒绝或暂缓的信号
身份概念只由属性定义,没有独立生命周期需要主键、审计历史或独立权限
相等Value Equality 的字段集合明确且可测试调用者各自决定哪些字段相等
校验Validation Boundary 能拒绝无效组合每个入口都先清理和判断字符串
变化Immutability 让更新产生新值,不影响旧引用需要原地变更并协调共享状态
替代已比较裸字段与实体对象,并保留撤回路径只为“面向对象”或代码风格增加包装

常见误区

可验证练习

练习

本组练习围绕 18.6 值对象,要求把身份、相等、校验和变化边界写入同一张评审卡。

问题 1:判断是否引入。 邮箱在用户、订单通知和审计消息中都以字符串出现,格式规则重复。值对象的证据是什么?

问题 2:处理更新。 订单复制后把地址改为新城市,原订单和复制订单都引用同一个地址实例。应该原地修改吗?

问题 3:处理跨对象规则。 地址值对象需要知道客户是否欠款才能决定是否允许配送。这个判断应该放在哪里?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

Value Object

以属性值定义相等、通常不可变且没有独立生命周期的小对象。

Value Equality

只比较组成属性而不比较对象身份的相等规则。

Immutability

创建后不直接改变内部值,变化通过构造或转换产生新值的特性。

Validation Boundary

把外部表示转换为经过校验的领域值并拒绝无效输入的边界。

Primitive Obsession

过度使用字符串或数字表达业务概念,导致语义和校验规则在调用处重复的倾向。

本章小结

掌握 18.6 值对象的标志,不是给每个字段增加一个类,而是能在订单系统基础设施替换中解释“外部表示 → 校验边界 → 值相等 → 不可变更新 → 持久化转换”的责任链。值对象适合没有独立身份、但有稳定语义和共享校验的概念;当规则跨对象、需要历史权限或必须原地协调状态时,应拒绝当前封装并选择实体、聚合或领域服务。

前后导航

来源与改写范围

资料与写作方式声明

本章以Martin Fowler《企业应用架构模式》与公开模式目录权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…