别在「保存父实体」里偷偷改子实体关联:层级模型里属性与关系为什么要分开
讨论一个常见却容易被忽略的设计问题:更新父资源时,是否应该在同一个请求里提交一整份子资源的关联 ID 列表?
1. 一个 Save 按钮,两类变更
在项目协作、订单履约、组织架构等系统里,很常见父子层级:例如项目下有多个任务,部门下有多个成员,订单下有多条行项目。子记录往往通过外键(Foreign Key)指向父记录(如 tasks.project_id)——关联的持久化真相通常在子表的外键,而不是父表上再存一列「关联 ID 列表」。
产品需求很直观:在「编辑项目」页多选任务,点保存,项目信息和成员任务一起生效。
实现上也「顺手」:扩展 UpdateProject 接口,请求体里增加 taskIds,先更新项目字段,再对当前挂在该项目下的任务做集合 diff(移除不在列表中的、挂上新增的)。
但用户心智往往是两件事:
- 我在改父实体本身(名称、状态、负责人……)
- 我在管谁和它有关联(下列表、成员、子任务)
把它们绑进同一个 HTTP 请求、同一个应用服务方法,短期能交付,长期却会在语义、并发和失败恢复上持续付利息。下面用业界常见的设计原则说明原因,并给出可落地的拆分方向。
2. 原则一:一个命令,只讲一种一致性故事(DDD 聚合)
在领域驱动设计(Domain-Driven Design,DDD)里,聚合(Aggregate)划定了一次事务内必须保持一致的边界。
- 项目聚合(Project Aggregate):名称、状态、周期等,以项目为聚合根(Aggregate Root)。
- 任务聚合(Task Aggregate):任务内容、以及「属于哪个项目」——外键在任务侧,改关联往往意味着更新多个任务聚合(或多次领域行为),而不是在父表上维护一份冗余 ID 列表。
当你在「更新项目」用例里既改项目属性又全量同步 taskIds,应用层在做跨聚合编排。编排本身合理,但若对外仍叫「更新项目」,会带来:
- 接口语义与真实写库位置不一致(大量更新发生在任务表);
- 失败时可能出现「项目已保存、部分任务已改关联」的半状态;
- 客户端常被迫每次提交全量 ID 列表,并在
null、空数组、省略字段之间做易错的约定。
可引用的设计口号:
Don’t bundle cross-aggregate writes.
跨聚合的写操作,宜有独立的命令与界面,而不是贴在单聚合的 Save 上。
graph TD
classDef default rx:10,ry:10
subgraph ProjectAggregate["项目聚合 (Project Aggregate)"]
P(项目 Project<br/>名称、状态、周期)
end
subgraph TaskAggregate1["任务聚合 (Task Aggregate)"]
T1(任务 Task A<br/>内容、project_id)
end
subgraph TaskAggregate2["任务聚合 (Task Aggregate)"]
T2(任务 Task B<br/>内容、project_id)
end
P -.->|FK| T1
P -.->|FK| T2
style ProjectAggregate fill:#e1f5e1,stroke:#2e7d2e,stroke-width:2px
style TaskAggregate1 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style TaskAggregate2 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
3. 原则二:外键在哪张表,命令从哪条边界进来
当关联由子表的 project_id(可空外键)表达时,写关联更自然的入口通常是:
- 在子资源上更新外键(例如「编辑任务 → 选择所属项目」),或
- 显式的 Link / Unlink 用例(「将任务 A 加入项目 P」「将任务 A 从项目 P 移除」),
而不是「更新父资源时附带一串子 ID,由服务端遍历改写子表」。
父资源代管子资源关联,常见后果包括:
| 现象 | 原因 |
|---|---|
| 误清空关联 | 空列表与「未传字段」的语义在前后端难以统一 |
| 双入口冲突 | 详情页改关联 vs 编辑页整单保存,互相覆盖 |
| 测试成本高 | 同一接口需覆盖「只改属性」「只改关联」「两者都改」 |
可引用的设计口号:
Truth lives on the write side of the FK.
外键所在的一侧,宜作为关联变更的主用例入口。
4. 原则三:不是所有 Write 都要叫 Update(CQS / 用例切片)
命令查询分离(Command Query Separation,CQS) 常被理解为查询不改状态;更广的用法是:按用例拆分写命令。
UpdateProject—— 仅项目属性;LinkTaskToProject/UnlinkTaskFromProject(或对任务的PATCH只改project_id)—— 仅关联。
在 REST 风格 API 中,更接近把关联建模为子资源(Sub-resource)或对子资源的局部更新,例如:
1 | PATCH /tasks/{taskId} |
或:
1 | POST /projects/{projectId}/tasks |
而不是把所有关系塞进一个「万能」的父资源更新体:
1 | PUT /projects/{projectId} |
按垂直切片(Vertical Slicing)组织代码时,可按用例划分模块(维护项目 / 管理项目下的任务),避免一个 Controller 上的巨型 DTO 承担所有写场景。
graph TD
classDef default rx:10,ry:10,font-size:13px
subgraph UpdateProject["UpdateProject 命令"]
A(仅项目属性)
end
subgraph LinkTask["Link/Unlink 命令"]
B(仅关联变更)
end
subgraph AntiPattern["反模式:万能 Update"]
C(项目属性 + 关联列表)
end
style UpdateProject fill:#e1f5e1,stroke:#2e7d2e,stroke-width:2px
style LinkTask fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style AntiPattern fill:#ffebee,stroke:#c62828,stroke-width:2px
5. 原则四:详情页当 Hub,编辑页管属性
许多 B2B 产品的惯例是:
- 编辑表单:维护「文档型」主数据(名称、描述、日期、状态);
- 详情页(Hub):展示读模型(Read Model),并在此完成关系型操作(成员、子任务、依赖、标签)。
若将「管理项目下的任务」放在项目详情页:
- 列表通过「按项目查询任务」类接口加载;
- 添加:选择器选中任务 → 调用 link(设置
project_id); - 移除:行内操作 → unlink(清空或迁移外键);
- 编辑项目接口不再携带全量
taskIds,只更新项目字段。
这与 Master–Detail、任务型界面(完成单一动作)与文档型界面(整表保存)的区分一致,也有助于减少「只改了标题却丢了关联」类问题。
graph TD
classDef default rx:10,ry:10
subgraph EditPage["编辑页(文档型界面)"]
E1(项目名称)
E2(项目状态)
E3(负责人)
end
subgraph HubPage["详情页 Hub(任务型界面)"]
H1(任务列表)
H2(成员管理)
H3(标签设置)
end
style EditPage fill:#e1f5e1,stroke:#2e7d2e,stroke-width:2px
style HubPage fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
6. 两种实现路径:整单保存 vs 详情管关联
路径 A — 编辑页整单保存(常见 MVP)
- 编辑弹窗内嵌关联列表与选择器;
- 打开时加载当前关联并填入表单;
- 保存时提交全量
taskIds,服务端做 diff 同步。
优点: 一次提交、符合用户对「编辑 → 保存」的习惯,接口数量少。
代价: 跨聚合写捆绑、半失败状态、全量列表与并发覆盖;空列表与未传字段的语义需严格约定(例如是否一律视为「清空关联」)。
路径 B — 详情页管理关联(更清晰边界)
- 项目更新只改项目属性;
- 关联在详情页逐条或批量 link/unlink,每次操作对应更小范围的写与反馈。
优点: 命令与 UI 职责清晰,与 FK 所在侧一致。
代价: 接口与交互步骤略多,需产品设计配合。
二者都是业界真实选择;当关联落在外键子表而非父聚合内集合时,路径 B 往往更易长期维护。
graph TD
classDef default rx:10,ry:10
subgraph PathA["路径 A:整单保存"]
A1(编辑弹窗) --> A2(提交全量 taskIds)
A2 --> A3(服务端 diff 同步)
A3 --> A4(项目表 + 任务表)
end
subgraph PathB["路径 B:详情页管理"]
B1(编辑页) --> B2(仅更新项目属性)
B3(详情页 Hub) --> B4(Link / Unlink)
B4 --> B5(仅更新任务表)
end
style PathA fill:#ffebee,stroke:#c62828,stroke-width:2px
style PathB fill:#e1f5e1,stroke:#2e7d2e,stroke-width:2px
7. 何时可以绑在一起,何时宜拆开
| 绑在一起(可接受) | 拆开(更推荐) |
|---|---|
| MVP、人力紧、关联变更很少 | 关联变更是高频、多人协作 |
| 业务强需求「父+子必须同一事务提交」 | 可接受分步保存、逐步生效 |
| 仅有表单、无详情 Hub | 有稳定的详情页与独立管理流 |
| 关联真在父聚合内(值对象 Value Object / 集合) | 关联在子表外键(跨聚合) |
父子通过子表外键建模时,多数属于右列。
8. 总结:设计评审与文档里可用的三句话
- 更新父资源的命令,只应维护父聚合根上的字段;跨表关联用独立用例或子资源 API。
- 详情页适合回答「谁和我有关」;编辑页适合回答「我是什么」。
- 每个 Save 按钮问一句:有几个聚合(或几张表的写边界)在变? 若大于一,考虑拆命令、拆界面、拆测试。
9. 延伸阅读(原则与关键词)
- DDD:Aggregate、Consistency Boundary、Application Service
- CQS / CQRS:命令按用例拆分、读写分离
- REST:子资源、避免万能 PUT body
- SRP / Separation of Concerns:API 与 UI 块职责对齐
- 信息架构:Hub page、Master–Detail、任务型 vs 文档型 UI