12.5 嵌入值

把没有独立身份的小值对象展开到所有者表,保留值语义并控制空值、更新与查询边界。

学习目标

  • 能解释为什么地址、金额等小对象可以展开为所有者表的列,以及身份边界在哪里
  • 能设计一次对象到表、再从表重建对象的映射,处理全空、部分空和字段演进
  • 能回答:当值对象需要独立查询、独立生命周期或频繁变化时,为什么应拒绝嵌入值

为什么 12.5 嵌入值 值得单独学习

先想象订单页面上的收货地址:用户把它当成一个完整的地址填写,数据库却可能只看到城市、街道和邮编三列。两边都能保存数据,但如果没有一条清楚的转换规则,读取、修改和校验就会各自发明一套解释。

这一章解决的是“一个小块信息应该跟着谁走”的问题。把它留在单独表里,会增加连接和生命周期管理;把它直接塞进任何表里,又容易产生半截地址、重复校验和难以迁移的列。读完后,你应该能说明选择背后的边界,而不是只记住一个模式名。

本页以 2024 年中文版公开目录限定 12.5 嵌入值 的范围,并依据 Martin Fowler 的作者图书页和模式目录独立重写。它不复现原书正文、插图或代码;目录只决定“要讲什么”,这里的案例、实验、判断题和答案均为本课程原创。

核心模型:对象保持完整,表保持扁平

什么是嵌入值

是一条映射决策:对象模型可以保留 Address 这样的完整结构,关系表却把 citystreetpostalCode 放在 orders 表中。这里嵌入的是值,不是另一个可以被单独引用的实体,因此不需要为它增加主键和 JOIN。

它最适合尺寸小、结构稳定、总是随同一个拥有者读写的对象,例如收货地址、金额和日期范围。收益是读取路径短、事务边界简单、值对象的校验可以集中在一个构造函数中;代价是拥有者的列会变宽,字段改名需要迁移表,而且查询层必须知道列前缀。

值对象的身份边界

的判断标准不是“它看起来小”,而是“换一个实例但值相同,业务是否仍认为它是同一个东西”。两个 Address("上海", "南京路", "200001") 可以互相替换,订单只关心地址内容,不关心地址对象的数据库编号,这就是嵌入的身份前提。

反过来,如果地址要被多个订单共享、单独授权、独立审计或频繁更新,它已经有了自己的生命周期。此时继续把它当成值,会让一个订单的修改意外影响另一个订单,应该改用独立表或明确的引用关系。

所有者表与映射器

决定嵌入字段的命名、可空性和事务边界。以 orders 为例,shipping_cityshipping_streetshipping_postal_code 都属于订单行;它们不能被另一个聚合悄悄复用,也不能绕过订单规则单独更新。

负责两次转换的通常是 :写出时拆开 shippingAddress,读入时把三列交给 Address 构造函数。映射器只做结构转换,不替订单决定地址是否可配送;业务规则应留在值对象或订单聚合中。

type Address = {
  city: string;
  street: string;
  postalCode: string;
};
 
type Order = {
  id: string;
  shippingAddress: Address | null;
};
 
// orders.shipping_city / shipping_street / shipping_postal_code

三个必须先定下来的设计判断

1. 什么时候算“没有独立身份”

问三个问题:它是否能脱离订单被单独加载?它是否需要自己的权限、审计和版本?它是否可能被多个拥有者共享?三个问题都回答“否”,嵌入值才有合理的身份边界。不要因为 ORM 支持 embedded 配置,就跳过这次业务判断。

2. 如何保持值语义

读入时把列一次性组装成新对象,写回时把对象一次性展开成一组列。更新地址时替换整个 shippingAddress,而不是先写城市、过一会儿再写街道。这样,数据库提交和对象替换都围绕同一个值发生,审计也能看见一次完整变化。

3. 如何处理空值组合

shipping_cityshipping_streetshipping_postal_code 全为空,可以表示“没有收货地址”;只有其中一列为空,则通常表示坏数据。映射器要在读入时拒绝部分空组合,表约束也要阻止它们写入。若业务确实允许只填城市,就把这个状态建成明确的值对象变体,不要让三个可空列自行组合出第四种语义。

专属设计实验:订单地址的往返映射

先预测:把 shipping_street 单独改掉而不重新构造 Address,会不会破坏对象中的值语义?再用下图逐步检查“保留对象 → 展开列 → 重建对象”的三个时刻;打开故障模式,观察部分空值为何应在映射边界被拒绝。

专属交互图 · 当前关注:对象语义
Embedded Value:对象字段 ↔ 所有者表列Order 对象id: "o-204"total: 299.00shippingAddress: Addresscity: "上海"street: "南京路"postalCode: "200001"AddressMapperflatten(Address)→ orders.shipping_*no id · no JOIN · one transactionorders 表id: "o-204"shipping_city: "上海"shipping_street: "南京路"shipping_postal: "200001"读取:三列 → Address写入:Address → 三列读写都以完整值为单位核心约束:值对象无独立身份;所有列必须作为一个值被校验、写入和重建可播放、单步、拖动进度;打开故障模式,再点重置回到对象起点

第 1 / 4 步 · ① 对象侧:Address 是一个完整值,没有独立表或主键

先看对象,再看列,最后看往返和拒绝条件。

嵌入值把 Address 的字段展开到订单表;没有独立表和 JOIN,但必须把空值组合和整体更新规则写清楚。

三个阶段快照

下面的 Stepper 用三张阶段快照配合上面的可播放主图。先逐步看静态证据,再回到主图拖动时间线或注入故障。

分步1 / 3

1. 保留对象语义

订单拥有一个完整的 Address 值对象;它没有独立表、主键或跨订单共享的身份。

专属交互图 · 当前关注:对象语义
Embedded Value:对象字段 ↔ 所有者表列Order 对象id: "o-204"total: 299.00shippingAddress: Addresscity: "上海"street: "南京路"postalCode: "200001"AddressMapperflatten(Address)→ orders.shipping_*no id · no JOIN · one transactionorders 表id: "o-204"shipping_city: "上海"shipping_street: "南京路"shipping_postal: "200001"读取:三列 → Address写入:Address → 三列读写都以完整值为单位核心约束:值对象无独立身份;所有列必须作为一个值被校验、写入和重建可播放、单步、拖动进度;打开故障模式,再点重置回到对象起点
嵌入值把 Address 的字段展开到订单表;没有独立表和 JOIN,但必须把空值组合和整体更新规则写清楚。

选择与拒绝矩阵

评审问题选择嵌入值的证据应拒绝或改用独立表的信号
身份值相同即可替换,总跟随一个订单读写需要独立 ID、权限、审计或共享
变化地址字段一起替换,事务边界清楚某一字段频繁变化且不应触发订单更新
空值全空和部分空有明确规则,读写两端都校验三列自由组合,坏数据只能在页面暴露
查询主要随订单详情读取,不单独筛选地址需要按地址独立搜索、分页或统计
迁移字段数量少且结构稳定值对象持续增长,表宽度和迁移成本失控

常见误区

本章小结

  • 嵌入值保留对象语义,但把字段展开到唯一的所有者表。
  • 值对象没有需要外部追踪的独立身份,内容相同即可替换。
  • 映射器负责拆列与重建,不替业务对象作出生命周期决定。
  • 全空、部分空和字段演进必须在对象与表两侧使用同一套规则。
  • 独立查询、共享、审计或高频变化出现时,应拒绝继续嵌入。

本章练习

练习

问题 1: 订单有 Address(city, street, postalCode)。请判断它是否适合嵌入 orders 表,并列出至少两个支持判断的证据和一个拒绝信号。

问题 2: 设计映射器的读写契约:当三列全为空、恰好只有 shipping_street 为空、三列都有值时,分别返回什么?

问题 3: 改造下面的保存逻辑,使它不会留下半个地址。你会把哪一层的责任放在事务里?

await orders.updateCity(orderId, next.city);
await orders.updateStreet(orderId, next.street);
await orders.updatePostalCode(orderId, next.postalCode);

名词解释

名词解释

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

嵌入值

不单独建表的小对象:保存时拆成拥有者表的几列,读取时再组合回来。

值对象

只看内容、不看编号的对象;两份内容相同的地址可以互相替换。

所有者表

承担值对象生命周期的主表,例如保存订单地址列的 orders 表。

映射器

负责把对象拆成列、再把列组装回对象的持久化边界。

空值组合

多个可空列同时出现的状态组合;全空和部分空必须有明确不同的规则。

不可变性

创建后的值不在原地改写;需要变化时用新值替换旧值,便于校验和审计。

出处声明

前后导航

讨论

评论区加载中…