本篇要搞懂的 2 个概念:聚合(Aggregate)、聚合根(AggregateRoot)。
承接前文:04 讲学了实体和值对象——领域模型的"基础积木"。05 讲再往上一层:把基础积木按业务规则组合起来,形成业务一致性的最小单元,这就是聚合;而聚合的"负责人"叫聚合根。
到本讲为止,DDD 的层级体系就完整了:
领域:整个业务(02 讲)
↓ 拆分
子域:核心域 / 通用域 / 支撑域(02 讲)
↓ 圈定语义边界
限界上下文:通用语言的"地盘"(03 讲)
↓ 按业务规则组合
聚合:业务一致性的最小单元(05 讲) ← 本讲
↓ 由谁负责
聚合根 + 实体 + 值对象(04 + 05 讲)
聚合(Aggregate)
为什么需要聚合?
04 讲学了实体和值对象——但只有它们还不够。很多业务场景需要多个对象协同完成一件事:
- 电商下单时要同时:创建订单、生成订单项、记录收货地址、扣减库存、计算金额
- 投保时要同时:创建投保单、记录被保人、记录投保人、关联报价规则
如果让这些对象"各自为政",谁都能改谁,很容易出现数据不一致——比如订单标成"已支付"了,但订单项还没扣减库存,这就是 bug。
聚合就是为了解决这个问题:把业务上必须一起变化、必须保持一致的实体和值对象捆成一个整体来管理。
聚合的定义
聚合是由业务紧密关联的实体和值对象组合而成的领域对象集合,是 DDD 里数据修改和持久化的基本单元。
"基本单元"是这个定义的核心词,它意味着:
| 操作 | 行为 |
|---|---|
| 修改 | 要改就整个聚合一起改,不能只改一半 |
| 加载 | 从数据库读取时,整个聚合一起加载 |
| 保存 | 持久化时一次性写入,不会出现"主表存了、从表漏存"的情况 |
| 事务一致性 | 强一致性事务只在聚合内部保证;跨聚合用最终一致性 |
技术上,一个聚合对应一个仓储(Repository)——DDD 中持久化的统一入口,对外屏蔽底层是数据库、文件还是别的存储。
聚合的两个关键特征
- 有一个聚合根(AggregateRoot):聚合对外的唯一接口人,所有进出聚合的请求都必须经过它(下一节细讲)。
- 有一个清晰的边界:根据"业务紧密度"圈出来——哪些对象属于这个聚合、哪些不属于。
聚合之间是松耦合的:跨聚合协作只能通过聚合根 ID 引用,不能直接对象引用。按这种方式设计出来的微服务,自然就是"高内聚、低耦合"。
🪞 辅助理解:把整个 DDD 类比为一家公司——
DDD 层级 公司里对应什么 实体 / 值对象 一个个员工 聚合 一个部门(员工的组织单位) 聚合根 部门经理 限界上下文 整个事业部 部门内的员工天天协作、共享资料(聚合内强一致);跨部门协作必须走经理对接,而不是底下员工互相串门(聚合间通过 ID 引用、最终一致)。这就是"高内聚、低耦合"在组织上的体现。
一个具体例子:电商订单聚合
订单聚合(Order Aggregate)
├── 订单(聚合根,有订单号 ID)
├── 订单项(实体,记录每个商品的购买信息)
├── 收货地址(值对象)
├── 支付金额(值对象)
└── 下单时间(值对象)
这个聚合体现了所有特征:
- 统一加载:查一个订单时,订单 + 订单项 + 地址一起加载进内存。
- 统一修改:要改地址,得通过订单聚合的"修改地址"方法——订单自己知道哪些数据要一起变。
- 统一持久化:保存时一次性写入,绝不会"订单存了但订单项漏存"。
- 对外只能 ID:用户聚合要查这个订单,只能拿订单 ID 来查,不能直接绕过订单去访问订单项。
换个角度再走一遍订单例子(直觉版)
如果上面的定义还是飘,用这个角度再过一遍——聚合到底在防什么。
一个订单里装着这些东西:
订单(订单ID、状态、总金额) ← 实体(聚合根)
├─ 订单项1(商品、数量、单价) ← 实体(订单内部的)
├─ 订单项2(商品、数量、单价) ← 实体
└─ 收货地址(省市县街道) ← 值对象
为什么要把它们圈成一坨? 因为它们之间有一条必须同时成立的规则:
「订单总金额 = 所有订单项的(单价 × 数量)之和」
如果外部能绕过订单、单独去改某个订单项的数量,订单总金额就对不上了——数据就不一致了。聚合存在的全部意义,就是堵死这种"绕过去乱改"的口子。
聚合根 = 这台机器唯一的「门」。 这里订单就是聚合根,规矩是:外部想动聚合里任何东西,必须经过订单。
- ✅ 想加一个订单项?必须调
订单.增加订单项(...),由订单在内部顺手把总金额重算了。 - ❌ 不允许外部直接拿到「订单项2」偷偷改数量。订单项藏在订单后面,外面够不着。
一个关键判断:什么该圈进同一个聚合?
问自己——「这两个东西,必须在同一个事务里一起改、一起保证一致吗?」
- 订单 ↔ 订单项 → 是(改订单项必然要重算金额)→ 同一聚合
- 订单 ↔ 商品 → 不是(下单后商品涨价,不该影响已下的订单)→ 拆成两个聚合,订单里只存
商品ID
经验法则:聚合尽量小。圈太大,每次存取一个订单要拖一堆东西,还容易并发打架(呼应后面的"设计小聚合"原则)。
聚合在 DDD 分层中的位置
聚合属于 DDD 的领域层——领域层包含多个聚合,共同实现核心业务逻辑。业务逻辑写在哪一层,看它的粒度:
| 业务粒度 | 实现位置 |
|---|---|
| 单个实体内的逻辑 | 实体类的方法里(充血模型) |
| 同一聚合内、跨多个实体的逻辑 | 领域服务(Domain Service) |
| 跨多个聚合的逻辑 | 应用服务(Application Service) |
聚合根(AggregateRoot)
定义:聚合根也称为根实体,它既是实体,又是聚合的管理者。
为什么需要聚合根?
传统数据模型里每个实体都是对等的,如果允许实体之间无控制地互相调用、互相修改,就会导致数据不一致;用锁来防止又会增加复杂度、降低性能。
聚合根的存在,是为了用统一的业务规则保障聚合内数据一致性。
聚合根的三重身份
| 身份 | 职责 |
|---|---|
| 作为实体本身 | 拥有实体的属性和业务行为,实现自身的业务逻辑 |
| 作为聚合的管理者 | 在聚合内部协调实体和值对象按固定规则协同工作 |
| 作为对外接口人 | 接受外部任务、对外暴露聚合根 ID,外部不能绕过它直接访问内部实体 |
一条关键规则
聚合之间通过聚合根 ID 关联引用。如果需要访问另一个聚合的实体:必须先访问对方的聚合根,再由它导航到内部。外部对象不能直接访问聚合内的实体。
🪞 辅助理解:聚合根就像部门经理——既是部门的一员(实体),也是部门的管理者(管理实体和值对象)。
别的部门要找你部门的人办事,必须先经过经理;不能直接绕过经理去找具体员工——否则就乱套了。
怎样设计聚合?
DDD 用事件风暴来设计聚合,5 步法:
| 步骤 | 做什么 | 产出 |
|---|---|---|
| 1 | 采用事件风暴,梳理业务行为中所有的实体和值对象 | 一组散落的领域对象 |
| 2 | 从众多实体中选出聚合根 | 1 个或多个聚合根 |
| 3 | 找出与聚合根关联紧密的实体和值对象,构成聚合 | 聚合(含 1 个聚合根 + N 个实体 + N 个值对象) |
| 4 | 画出聚合内对象的引用和依赖关系 | 聚合内部结构图 |
| 5 | 多个聚合按业务语义划入同一个限界上下文 | 限界上下文 |
怎么判断一个实体是不是聚合根?
按四个特征判断:
- 是否有独立的生命周期?
- 是否有全局唯一 ID?
- 是否可以创建或修改其它对象?
- 是否有专门的模块来管理它?
四个条件同时满足,基本就是聚合根了。
一个完整例子:投保业务场景
按 5 步法走一遍:
Step 1:事件风暴梳理出实体和值对象——投保单、标的、客户、被保人、报价单、报价规则……
Step 2:找聚合根——投保单和客户符合"独立生命周期 + 全局 ID + 可创建修改其它对象 + 有专门模块管理",是两个聚合根。
Step 3:构建两个聚合——
| 聚合 | 聚合根 | 内部对象 |
|---|---|---|
| 客户聚合 | 客户 | 客户基本信息、联系方式等 |
| 投保聚合 | 投保单 | 报价单(实体)→ 报价规则(子实体)、投保人(值对象)、被保人(值对象)、标的(值对象) |
Step 4:画引用关系——投保单引用报价单实体,报价单又引用报价规则子实体。
关键细节:投保人和被保人在投保聚合里是值对象——数据是从客户聚合冗余过来的(通过客户 ID 关联取来)。即使未来客户聚合的数据变了,也不会影响投保单上的这些值对象——因为投保单上的是"那一刻的快照"。
Step 5:投保聚合和客户聚合按业务语义放进同一个"投保限界上下文"。
🪞 辅助理解:聚合之间的"冗余"很像电商订单上的"收货地址"——
- 用户改了"个人地址簿"里的地址(客户聚合改)→ 已经下单的订单不会跟着变(投保单上的值对象不变)
- 因为订单上记的是下单那一刻的快照,不是个引用
再用订单走一遍 5 步法(更贴近日常的例子)
投保场景偏保险业务,下面换成电商下单,把同样的 5 步再走一遍,体会一下"流程是通用的"。
Step 1 · 事件风暴梳理对象
把下单这件事涉及的领域对象全列出来(先不管谁管谁):
订单、订单项、商品、收货地址、支付金额、下单时间、用户、优惠券……
Step 2 · 挑聚合根(套四个判断)
| 候选 | 独立生命周期 | 全局 ID | 能创建/修改其它对象 | 有专门模块管理 | 结论 |
|---|---|---|---|---|---|
| 订单 | ✅ | ✅ 订单号 | ✅ 创建并管理订单项 | ✅ 订单服务 | 聚合根 |
| 商品 | ✅ | ✅ 商品 ID | ✅ | ✅ 商品服务 | 聚合根(另一个聚合的) |
| 用户 | ✅ | ✅ 用户 ID | ✅ | ✅ 用户服务 | 聚合根(另一个聚合的) |
| 订单项 | ❌ 依附订单 | 聚合内唯一即可 | ❌ | ❌ | 普通实体,归订单管 |
→ 关键判断:订单项离开订单没有任何意义(不会有人单独查一条"订单项"),所以它不是聚合根,归到订单聚合内部。
Step 3 · 圈聚合
| 聚合 | 聚合根 | 内部对象 |
|---|---|---|
| 订单聚合 | 订单 | 订单项(实体)、收货地址(值对象)、支付金额(值对象)、下单时间(值对象) |
| 商品聚合 | 商品 | 商品基本信息、库存、价格等 |
| 用户聚合 | 用户 | 用户资料、地址簿等 |
→ 边界判断用上一节那条规则:「改它必须和订单一起改、保证一致吗?」 订单项改了要重算订单总金额 → 圈进来;商品涨价不该影响已下订单 → 留在商品聚合外面。
Step 4 · 画引用关系
- 订单聚合内部:订单 → 订单项(直接对象引用,订单导航到订单项)。
- 订单聚合对外:订单项里只存
商品ID,不存整个商品对象;收货地址是下单那一刻从用户地址簿冗余过来的值对象(快照)。
订单聚合
订单(订单号)
├─ 订单项[ 商品ID(→商品聚合)、数量、单价 ] ← 跨聚合只留 ID
├─ 收货地址(值对象,下单快照) ← 用户改地址簿不影响它
└─ 支付金额、下单时间(值对象)
→ 这正好踩中"用户改了地址簿、已下订单不跟着变"的快照逻辑,和投保单上的被保人值对象是同一回事。
Step 5 · 划入限界上下文
订单聚合归「交易/订单上下文」,商品聚合归「商品上下文」,用户聚合归「用户上下文」。跨上下文协作只通过聚合根 ID + 应用层组合,不做跨库 JOIN。
对照看:投保例子里"投保单"对应这里的"订单",“报价单"对应"订单项”,“被保人/投保人值对象"对应"收货地址值对象”——结构完全同构,换的只是业务名词。这就是 5 步法可复用的地方。
聚合的 5 个设计原则
来自《实现领域驱动设计》,原文有点绕,逐条解释:
1. 在一致性边界内建模真正的不变条件
聚合用来封装真正的不变性,不是简单地把对象组合在一起。聚合内有一套不变的业务规则,所有实体和值对象都遵守它;边界之外的东西与聚合无关。
→ 这是"高内聚"的根本来源。
2. 设计小聚合
聚合太大会导致:
- 实体太多 → 内部管理复杂
- 高频操作时并发冲突或数据库锁 → 系统可用性下降
- 业务变化时不得不重构整个聚合
小聚合则更灵活,更能适应业务变化。
🪞 辅助理解:聚合做大了就像一个超级部门管太多业务——开会拖沓、改动牵动全员、版本发布也慢。拆小后每个部门职责清晰,迭代独立。
3. 通过唯一标识引用其它聚合
聚合之间只能通过聚合根 ID 关联,不能直接对象引用。
如果把外部聚合的对象直接放进聚合边界内管理,会导致:
- 聚合的边界不清晰
- 聚合之间耦合度上升
4. 在边界之外使用最终一致性
- 聚合内:数据强一致(一次事务搞定)
- 聚合间:数据最终一致(异步补齐)
一次事务中最多只能更改一个聚合的状态。如果一次业务操作涉及多个聚合状态变化,应当用领域事件异步驱动(这是 06 讲的内容)。
🪞 辅助理解:强一致 vs 最终一致就像夫妻两个人买东西——夫妻两个人买(同一个聚合内)必须当场对账;隔壁邻居买东西(跨聚合)只要"晚上算账时对得上"就行,不必每一分钟都完全同步。
5. 通过应用层实现跨聚合的服务调用
为了实现聚合之间的解耦、以及未来以聚合为单位拆分微服务,避免:
- 跨聚合的领域服务调用
- 跨聚合的数据库表 JOIN
跨聚合的协同应该走应用服务(Application Service)这一层。
总览:一句话浓缩 5 个原则
🪞 辅助理解:5 个原则其实就一句话——“聚合内紧紧抱团,聚合外松松握手”。
- 抱团:聚合内强一致、直接对象引用、共享一个仓储、统一规则
- 握手:聚合外通过 ID 引用、最终一致、应用层组合、不 JOIN
需要注意:这些原则不是教条。当遇到性能瓶颈、技术能力限制、全局事务管理等现实约束时,可以突破——一切以解决实际问题为出发点。
总结
聚合是 DDD 领域模型中由业务紧密关联的实体和值对象组成的领域对象集合,是数据修改和持久化的基本单元——每一个聚合对应一个仓储。聚合有一个聚合根作为对外入口、有一个明确的边界保证内部高内聚、与其他聚合之间松耦合。在 DDD 分层架构中,聚合属于领域层,承担核心业务逻辑:聚合内部的逻辑写在实体方法里(充血模型),跨实体的写领域服务,跨聚合的走应用服务。
聚合根是聚合的"负责人",既是实体也是管理者。它有三重身份:作为实体拥有自己的属性和行为;作为管理者协调聚合内实体和值对象按规则协同;作为对外接口人通过 ID 接受外部请求。一条不能破的规则:聚合之间只能通过聚合根 ID 互相引用,外部不能直接访问聚合内的实体——这是保证聚合边界清晰、数据一致性的关键。
怎样设计聚合遵循 5 步法(事件风暴梳理对象 → 选聚合根 → 圈聚合 → 画引用关系 → 划入限界上下文)。判断聚合根看四个特征:独立生命周期、全局 ID、能创建/修改其它对象、有专门模块管理。一个完整聚合就是"1 个聚合根 + N 个实体 + N 个值对象"的组合,多个聚合再按语义聚到一个限界上下文里。
5 个设计原则可以浓缩成一句话——聚合内紧紧抱团(强一致、对象引用、统一仓储),聚合外松松握手(ID 引用、最终一致、应用层组合)。这套约束让聚合在业务上保证一致性、在技术上方便拆分微服务。
先把"是什么"回答清楚
| 概念 | 官方定义(DDD 标准说法) | 一句话大白话 | 例子 |
|---|---|---|---|
| 聚合(Aggregate) | 业务紧密关联的实体和值对象组合而成的领域对象集合,是数据修改和持久化的基本单元 | 一个"部门"——把功能相关的实体和值对象组织起来 | 投保聚合(投保单 + 报价单 + 投保人 + 被保人) |
| 聚合根(AggregateRoot) | 聚合的根实体;既是实体本身,又是聚合的管理者和对外接口 | 部门经理——既是组员,也是负责人 | 投保单(投保聚合的聚合根) |
四种领域对象的差异对比
| 维度 | 聚合 | 聚合根 | 实体 | 值对象 |
|---|---|---|---|---|
| 本质 | 一组对象的集合 | 一个特殊的实体 | 有 ID 的对象 | 无 ID 的属性集 |
| 唯一性 | — | 全局唯一 ID | ID 在聚合内唯一即可 | 无 ID,按属性值判断相等 |
| 生命周期 | 包含多个对象的生命周期 | 独立 | 依附聚合根,由聚合根管理 | 无生命周期,用完即扔 |
| 可变性 | 可变(一致性修改单元) | 可变 | 可变 | 不可变 |
| 持久化 | 对应一个仓储 | 是聚合的持久化入口 | 一般会持久化,但不一定 1:1 | 嵌入实体表,不单独持久化 |
| 跨聚合引用 | — | 只能用聚合根 ID 引用 | 只能引用聚合内的对象 | 尽量只引用值对象 |
一句话速记
聚合是"部门",聚合根是"部门经理";部门内对象直接对话,部门之间通过经理 ID 握手。
落地建议:怎么找到你的聚合?
按以下顺序实操:
- 事件风暴:列出业务中所有的命令、事件、实体、值对象。
- 挑聚合根:用四个判断(独立生命周期 / 全局 ID / 能创建修改其它对象 / 有专门模块管理)挑出聚合根候选。
- 圈紧密关联的对象:在每个聚合根旁边圈一圈"被它管理"或"它必须协调"的实体和值对象。
- 检查不变性:聚合内是否有一套"必须同时成立"的业务规则?没有的话可能切大了。
- 检查耦合:聚合之间是否只通过 ID 引用?JOIN 多了就要重新切。
- 小聚合优先:宁愿多切几个聚合,也别整一个超大聚合。
思考题
回到自己的项目:
- 一个常见的业务流程(如下单、注册、审批)里,哪几个对象天然有"独立生命周期 + 全局 ID + 管理其它对象" 的特征?它们就是聚合根候选。
- 你目前代码里有没有跨聚合 JOIN 或跨聚合直接对象引用?这些是潜在的耦合点。
- 业务上有没有"一次操作必须同时修改多个聚合"的需求?如果有,可以思考能否拆成"先改 A 聚合 → 发领域事件 → 异步改 B 聚合"的最终一致方案(这是下一讲的预热)。
