12.5 嵌入值
把没有独立身份的小值对象展开到所有者表,保留值语义并控制空值、更新与查询边界。
学习目标
- 能解释为什么地址、金额等小对象可以展开为所有者表的列,以及身份边界在哪里
- 能设计一次对象到表、再从表重建对象的映射,处理全空、部分空和字段演进
- 能回答:当值对象需要独立查询、独立生命周期或频繁变化时,为什么应拒绝嵌入值
为什么 12.5 嵌入值 值得单独学习
先想象订单页面上的收货地址:用户把它当成一个完整的地址填写,数据库却可能只看到城市、街道和邮编三列。两边都能保存数据,但如果没有一条清楚的转换规则,读取、修改和校验就会各自发明一套解释。
这一章解决的是“一个小块信息应该跟着谁走”的问题。把它留在单独表里,会增加连接和生命周期管理;把它直接塞进任何表里,又容易产生半截地址、重复校验和难以迁移的列。读完后,你应该能说明选择背后的边界,而不是只记住一个模式名。
本页以 2024 年中文版公开目录限定 12.5 嵌入值 的范围,并依据 Martin Fowler 的作者图书页和模式目录独立重写。它不复现原书正文、插图或代码;目录只决定“要讲什么”,这里的案例、实验、判断题和答案均为本课程原创。
核心模型:对象保持完整,表保持扁平
什么是嵌入值
↡把没有独立身份的小对象的字段展开到拥有者表中,读取时再按规则重建它。是一条映射决策:对象模型可以保留 Address 这样的完整结构,关系表却把
city、street、postalCode 放在 orders
表中。这里嵌入的是值,不是另一个可以被单独引用的实体,因此不需要为它增加主键和
JOIN。
它最适合尺寸小、结构稳定、总是随同一个拥有者读写的对象,例如收货地址、金额和日期范围。收益是读取路径短、事务边界简单、值对象的校验可以集中在一个构造函数中;代价是拥有者的列会变宽,字段改名需要迁移表,而且查询层必须知道列前缀。
值对象的身份边界
↡只由属性值定义、没有需要被外部单独追踪身份的对象。
的判断标准不是“它看起来小”,而是“换一个实例但值相同,业务是否仍认为它是同一个东西”。两个
Address("上海", "南京路", "200001")
可以互相替换,订单只关心地址内容,不关心地址对象的数据库编号,这就是嵌入的身份前提。
反过来,如果地址要被多个订单共享、单独授权、独立审计或频繁更新,它已经有了自己的生命周期。此时继续把它当成值,会让一个订单的修改意外影响另一个订单,应该改用独立表或明确的引用关系。
所有者表与映射器
↡承担值对象生命周期、在关系库中保存其展开字段的那张主表。决定嵌入字段的命名、可空性和事务边界。以 orders
为例,shipping_city、shipping_street 和 shipping_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_city、shipping_street、shipping_postal_code 全为空,可以表示“没有收货地址”;只有其中一列为空,则通常表示坏数据。映射器要在读入时拒绝部分空组合,表约束也要阻止它们写入。若业务确实允许只填城市,就把这个状态建成明确的值对象变体,不要让三个可空列自行组合出第四种语义。
专属设计实验:订单地址的往返映射
先预测:把 shipping_street 单独改掉而不重新构造 Address,会不会破坏对象中的值语义?再用下图逐步检查“保留对象 → 展开列 → 重建对象”的三个时刻;打开故障模式,观察部分空值为何应在映射边界被拒绝。
第 1 / 4 步 · ① 对象侧:Address 是一个完整值,没有独立表或主键
先看对象,再看列,最后看往返和拒绝条件。
三个阶段快照
下面的 Stepper 用三张阶段快照配合上面的可播放主图。先逐步看静态证据,再回到主图拖动时间线或注入故障。
1. 保留对象语义
订单拥有一个完整的 Address 值对象;它没有独立表、主键或跨订单共享的身份。
选择与拒绝矩阵
| 评审问题 | 选择嵌入值的证据 | 应拒绝或改用独立表的信号 |
|---|---|---|
| 身份 | 值相同即可替换,总跟随一个订单读写 | 需要独立 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表。- 映射器
负责把对象拆成列、再把列组装回对象的持久化边界。
- 空值组合
多个可空列同时出现的状态组合;全空和部分空必须有明确不同的规则。
- 不可变性
创建后的值不在原地改写;需要变化时用新值替换旧值,便于校验和审计。