别在「保存父实体」里偷偷改子实体关联:层级模型里属性与关系为什么要分开

讨论一个常见却容易被忽略的设计问题:更新父资源时,是否应该在同一个请求里提交一整份子资源的关联 ID 列表?

1. 一个 Save 按钮,两类变更

在项目协作、订单履约、组织架构等系统里,很常见父子层级:例如项目下有多个任务部门下有多个成员订单下有多条行项目。子记录往往通过外键(Foreign Key)指向父记录(如 tasks.project_id)——关联的持久化真相通常在子表的外键,而不是父表上再存一列「关联 ID 列表」。

产品需求很直观:在「编辑项目」页多选任务,点保存,项目信息和成员任务一起生效。

实现上也「顺手」:扩展 UpdateProject 接口,请求体里增加 taskIds,先更新项目字段,再对当前挂在该项目下的任务做集合 diff(移除不在列表中的、挂上新增的)。

但用户心智往往是两件事:

  1. 我在改父实体本身(名称、状态、负责人……)
  2. 我在管谁和它有关联(下列表、成员、子任务)

把它们绑进同一个 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
2
PATCH /tasks/{taskId}
{ "projectId": "proj-123" }

或:

1
2
POST /projects/{projectId}/tasks
{ "taskId": "task-456" }

而不是把所有关系塞进一个「万能」的父资源更新体:

1
2
PUT /projects/{projectId}
{ "name": "...", "taskIds": ["t1","t2","t3"] }

垂直切片(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. 总结:设计评审与文档里可用的三句话

  1. 更新父资源的命令,只应维护父聚合根上的字段;跨表关联用独立用例或子资源 API。
  2. 详情页适合回答「谁和我有关」;编辑页适合回答「我是什么」。
  3. 每个 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