前言
上一篇我们讲了实体和值对象。但实体的粒度太小,单独的实体无法表达一个完整的业务行为。
比如“学员选课”这件事,至少涉及:学员、课程、选课记录、订单、优惠信息……这么多对象,它们应该怎么组织?
这就是聚合要解决的问题。
一、什么是聚合?
聚合(Aggregate):由业务和逻辑紧密关联的实体和值对象组合而成的一个一致性边界。
在聚合内部,所有对象必须满足共同的业务规则,并且作为一个整体被修改和持久化。
聚合的关键词是“一致性边界”。边界内的对象,状态必须强一致。
二、什么是聚合根?
聚合根(Aggregate Root):聚合的“主实体”,是聚合对外的唯一接口。
特点:
- 有全局唯一 ID。
- 聚合内其他对象通过聚合根被外部访问。
- 外部对象不能直接持有聚合内部实体的引用。
三、举例:选课场景
学员选课涉及的对象:
学员(Student)
├── 选课单(CourseEnrollment)── 聚合根
│ ├── 选课明细(EnrollmentItem)
│ ├── 优惠券(Coupon)
│ ├── 订单(Order)
│ └── 价格快照(PriceSnapshot)
└── 学员档案(StudentProfile)── 独立聚合
这里“选课单 + 选课明细 + 优惠券 + 订单 + 价格快照”组成一个聚合,选课单是聚合根。
学员档案是另一个独立聚合,不放在选课聚合里,因为学员的姓名、地址、手机号的变化和选课没关系,生命周期不同、规则不同、修改频率不同。
四、为什么不能“一个大聚合包含所有”
如果把所有对象都放进一个大聚合:
// ❌ 错误:一个大聚合
class Student {
StudentId id;
Address homeAddress; // 值对象
List<CourseEnrollment> enrollments; // 选课
List<Homework> homeworks; // 作业
List<Note> notes; // 笔记
List<Coupon> coupons; // 优惠券
// ... 所有跟学员相关的东西
}
会出现这些问题:
- 修改冲突严重:A 改选课、B 改作业、C 改笔记,全都在同一个聚合上操作,数据库锁严重。
- 并发性能极差:高频操作互相阻塞。
- 边界模糊:选课逻辑和学员信息逻辑混在一起,业务代码难维护。
- 无法独立演进:选课要改个字段,影响整个学员聚合。
聚合一定要“小”。一个聚合只做一件事,一致性边界只覆盖真正需要强一致的那部分。
五、5 步设计法
我在线上做领域建模时,几乎每次都按这 5 步走:
步骤 1:事件风暴,梳理所有实体
把业务流程走一遍,列出所有出现的实体和值对象。
选课场景:学员、课程、选课单、选课明细、优惠券、订单、价格快照……
步骤 2:找出聚合根
判断一个实体是否是聚合根,问四个问题:
- 是否有独立生命周期?(能脱离其他对象独立存在)
- 是否有全局唯一 ID?
- 是否可以创建或修改其他对象?(即“管理其他对象”)
- 是否有专门的模块来管这个实体?
选课单满足全部四条 → 聚合根。
学员也满足全部四条 → 另一个聚合的聚合根。
步骤 3:找出聚合内的紧密依赖对象
围绕选课单,找出业务上必须和它一起修改的对象:
- 选课明细(选了什么课)
- 优惠券(用了什么优惠)
- 订单(要付多少钱)
- 价格快照(下单时的价格,避免后续涨价导致纠纷)
这四个对象和选课单强相关,所以都属于这个聚合。
而学员的笔记、作业、评价——这些虽然属于学员,但和选课没关系,应该放在其他聚合里。
步骤 4:画出对象引用关系
class CourseEnrollment { // 聚合根
EnrollmentId id;
StudentId studentId; // 引用其他聚合的 ID
CourseId courseId; // 引用其他聚合的 ID
List<EnrollmentItem> items; // 聚合内实体
Coupon coupon; // 聚合内值对象/实体
Order order; // 聚合内实体
PriceSnapshot priceSnapshot; // 聚合内值对象
}
关键原则:聚合之间只通过 ID 引用,不通过对象引用。
// ✅ 正确:聚合之间通过 ID
class CourseEnrollment {
StudentId studentId; // ID,不是 Student 对象
}
// ❌ 错误:聚合之间对象引用
class CourseEnrollment {
Student student; // 直接引用其他聚合的对象
}
为什么?因为对象引用会导致一个聚合的内部对象被外部直接修改,破坏聚合的一致性边界。
步骤 5:把聚合划到限界上下文
最后把这个聚合放到对应的限界上下文里。选课单聚合属于报名域。
六、5 条聚合设计原则
原则 1:在一致性边界内建模真正的不变条件
聚合存在的意义是封装业务的不变条件。
例:选课单的“价格 = 商品原价 - 优惠”这一规则,必须在聚合内被强保证。任何对选课单的修改,都不能让总价算错。
原则 2:设计小聚合
聚合越大,并发冲突越多,性能越差。聚合只装真正需要强一致的对象。
经验法则:一个聚合通常 3-7 个对象(聚合根 + 1-2 个实体 + 几个值对象)。超过这个范围大概率可以拆。
原则 3:通过唯一标识引用其它聚合
聚合之间只通过 ID 关联,不通过对象引用。这是为了保持边界的清晰。
// 跨聚合查询:通过 ID 加载
Student s = studentRepo.find(enrollment.getStudentId());
原则 4:边界之外使用最终一致性
聚合内强一致,聚合之间最终一致。
一次事务最多只能修改一个聚合。如果一次操作涉及多个聚合的修改,应该用领域事件异步通知(下一篇讲)。
// ✅ 正确:一次事务只改一个聚合
@Transactional
public void enrollStudent(StudentId sid, CourseId cid) {
CourseEnrollment e = enrollmentRepo.find(eid);
e.confirm(); // 改选课单聚合
enrollmentRepo.save(e);
// 通过领域事件异步通知其他聚合
}
原则 5:通过应用层实现跨聚合的服务调用
不要在聚合内调用其他聚合的领域服务。跨聚合的编排放到应用层。
// 应用服务:编排多个聚合
class EnrollStudentAppService {
void execute(StudentId sid, CourseId cid, CouponId couponId) {
Student s = studentRepo.find(sid); // 学员聚合
Course c = courseRepo.find(cid); // 课程聚合
Coupon cp = couponRepo.find(couponId); // 优惠券聚合
CourseEnrollment e = enrollmentFactory.create(s, c, cp); // 创建选课单
enrollmentRepo.save(e);
}
}
七、聚合、聚合根、实体、值对象的关系
| 概念 | 是否有 ID | 是否可变 | 是否有生命周期 | 在哪一层 |
|---|---|---|---|---|
| 聚合根 | ✅ | ✅ | ✅ | 领域层 |
| 实体 | ✅(聚合内唯一即可) | ✅ | ✅(依赖聚合根) | 领域层 |
| 值对象 | ❌ | ❌ | ❌ | 领域层 |
| 聚合 | 是一组对象 | - | - | 领域层 |
记忆口诀:聚合根是带头大哥,实体是小弟,值对象是装备。装备随用随扔,小弟依附大哥,大哥对外代表整个组织。
八、聚合的存储
一个聚合对应一个仓储(Repository)。
interface CourseEnrollmentRepository {
CourseEnrollment find(EnrollmentId id);
void save(CourseEnrollment enrollment);
void delete(EnrollmentId id);
}
仓储只针对聚合根,不针对聚合内的实体。外部对象要访问聚合内的实体,必须先通过聚合根。
九、常见误区
误区 1:把整个业务都塞进一个聚合
这是最常见的错误。小聚合是高性能的保障。
误区 2:聚合之间对象引用
// ❌ 错误
class CourseEnrollment {
Student student; // 直接引用
}
这种写法破坏了边界,一旦 student 被外部修改,CourseEnrollment 的不变条件就可能失效。
误区 3:把领域服务写在应用层
领域服务是领域层的概念,承载跨多个实体的领域逻辑。它跟应用服务的区别:
- 领域服务:表达业务概念(如“计算订单总价”)。
- 应用服务:表达用例流程(如“下单流程”)。
// 领域服务:业务逻辑
class PriceCalculator {
Price calculate(Course c, Coupon coupon) { /* 业务规则 */ }
}
// 应用服务:编排
class CreateEnrollmentAppService {
void execute(...) {
Price price = priceCalculator.calculate(c, coupon); // 调用领域服务
CourseEnrollment e = new CourseEnrollment(..., price);
enrollmentRepo.save(e);
}
}
十、聚合和微服务的关系
聚合和微服务不是一对一:
- 一个微服务可以包含多个聚合。
- 一个聚合可以独立成一个微服务(极致性能场景)。
经验法则:
- 常规情况:一个微服务包含 2-5 个聚合。
- 高频热点场景:把热点聚合单独拆成微服务。
演进路径:业务初期,一个微服务包含多个聚合;业务发展后,按聚合为单位逐步拆分。
总结
这一篇我们讲了:
- 聚合是一致性边界,不是简单的对象组合。
- 聚合根是聚合对外的唯一接口。
- 聚合要“小”,边界要清晰。
- 聚合之间只通过 ID 引用。
- 一次事务只改一个聚合。
- 跨聚合的逻辑放在应用层。
下一篇我们讲领域事件——怎么用事件机制,让多个聚合之间的协作解耦、异步、最终一致。