跳至正文
来两杯美式
返回

DDD 复杂业务系统设计(五):聚合与聚合根,一个“选课单”应该装多少东西

By 来两杯美式
发布于

前言

上一篇我们讲了实体和值对象。但实体的粒度太小,单独的实体无法表达一个完整的业务行为

比如“学员选课”这件事,至少涉及:学员、课程、选课记录、订单、优惠信息……这么多对象,它们应该怎么组织?

这就是聚合要解决的问题。

一、什么是聚合?

聚合(Aggregate):由业务和逻辑紧密关联的实体和值对象组合而成的一个一致性边界

在聚合内部,所有对象必须满足共同的业务规则,并且作为一个整体被修改和持久化

聚合的关键词是“一致性边界”。边界内的对象,状态必须强一致

二、什么是聚合根?

聚合根(Aggregate Root):聚合的“主实体”,是聚合对外的唯一接口。

特点:

三、举例:选课场景

学员选课涉及的对象:

学员(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;         // 优惠券
    // ... 所有跟学员相关的东西
}

会出现这些问题:

聚合一定要“小”。一个聚合只做一件事,一致性边界只覆盖真正需要强一致的那部分。

五、5 步设计法

我在线上做领域建模时,几乎每次都按这 5 步走

步骤 1:事件风暴,梳理所有实体

把业务流程走一遍,列出所有出现的实体和值对象

选课场景:学员、课程、选课单、选课明细、优惠券、订单、价格快照……

步骤 2:找出聚合根

判断一个实体是否是聚合根,问四个问题:

  1. 是否有独立生命周期?(能脱离其他对象独立存在)
  2. 是否有全局唯一 ID?
  3. 是否可以创建或修改其他对象?(即“管理其他对象”)
  4. 是否有专门的模块来管这个实体?

选课单满足全部四条 → 聚合根

学员也满足全部四条 → 另一个聚合的聚合根

步骤 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);
    }
}

十、聚合和微服务的关系

聚合和微服务不是一对一

经验法则

演进路径:业务初期,一个微服务包含多个聚合;业务发展后,按聚合为单位逐步拆分。

总结

这一篇我们讲了:

下一篇我们讲领域事件——怎么用事件机制,让多个聚合之间的协作解耦、异步、最终一致


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.DDD 复杂业务系统设计(一):为什么从“领域”开始,比从“数据库”开始更靠谱
  2. 02.DDD 复杂业务系统设计(二):领域与子域,把“学校/课程/学员”拆成三件事再说
  3. 03.DDD 复杂业务系统设计(三):限界上下文,为什么“课程”在教务和市场是两种东西
  4. 04.DDD 复杂业务系统设计(四):实体与值对象,把“学员”和“地址”放进代码的两种姿势
  5. 05.DDD 复杂业务系统设计(五):聚合与聚合根,一个“选课单”应该装多少东西
  6. 06.DDD 复杂业务系统设计(六):领域事件,让“作业提交”自动触发批改进度
  7. 07.DDD 复杂业务系统设计(七):分层架构,你的“业务代码”应该待在哪一层
  8. 08.DDD 复杂业务系统设计(八):整洁架构 vs 六边形架构 vs DDD 分层,我选哪一种
  9. 09.DDD 复杂业务系统设计(九):中台,在线教育平台到底应该共享什么
  10. 10.DDD 复杂业务系统设计(十):从战略到微服务,一张图跑通 DDD+中台+微服务
  11. 11.DDD 复杂业务系统设计(总结):从领域到微服务,一张图看懂 DDD 落地全流程

上一篇
设计模式之建造者模式:复杂对象的组装艺术
下一篇
设计模式之工厂模式:简单工厂、工厂方法与抽象工厂