DDD中多对多关系的不变量建模问题咨询
我正在学习DDD,在多对多关系中对不变量建模时遇到了困难。假设要建模一个学校排课系统,存在以下不变量:
Student不能重复选修同一门CourseStudent最多只能选修5门CourseCourse的选课学生数不能超过其最大容量
这是Course与Student之间的多对多关系,建模时难以同时满足这些不变量。从逻辑上看,Course和Student应作为独立的聚合根,同时Enrollment(选课记录)概念很重要,我将其设为Course聚合下的实体。
最初的代码实现
class Student { public id: number public age: number public email: string } class Course { public id: number public studentLimit: number public studentIds: List<number> public enrollStudent(studentId: number) { if (studentIds.length >= studentLimit || studentIds.find(id => id == studentId)) { throw new BadRequestException() } studentIds.push(studentId) } }
该实现可满足不变量1和3,但为满足不变量2,需要在Student端跟踪选课数量。若在Student实体中维护该数据,需同时更新两个聚合,因此我考虑将逻辑提取到更高层级的聚合——EnrollmentBoard中:
改进方案代码实现
class EnrollmentBoard { private courseDetails: List<CourseDetails> private studentEnrollments: List<StudentEnrollments> public enrollStudentInCourse(studentId: number, courseId: number) { const course = courseDetails.find(c => c.courseId == courseId) const studentEnrollment = studentEnrollments.find(s => s.studentId === studentId) if (course?.canAddStudent() && studentEnrollment?.canEnrollInAnotherCourse()) { course.addStudent() studentEnrollment.enrollInCourse(courseId) } } } class CourseDetails { public courseId: number public studentLimit: number public studentCount: number public canAddStudent(): boolean { return this.studentLimit - this.studentCount > 0 } public addStudent(): void { this.studentCount += 1 } } class StudentEnrollments { public studentId: number public courseIds: List<number> public canEnrollInAnotherCourse(): boolean { return this.courseIds.length < 5 } public enrollInCourse(courseId: number): void { this.courseIds.push(courseId) } }
此方案可解决不变量问题,但EnrollmentBoard聚合会变得非常庞大,对于整个大学的数万学生选课场景,内存加载压力大。我有两个疑问:
- 是否有无需加载大量数据即可强制执行这些不变量的方法?我曾考虑按学生专业/分类拆分
EnrollmentBoard,是否可行? - 同时存在两个
Student模型是否合理:一个作为聚合根的Student,另一个是EnrollmentBoard中的StudentEnrollments?前者用于更新学生姓名等信息,后者用于通过EnrollmentBoard处理选课以确保不变量。
1. 无需加载大量数据的不变量强制执行方案,以及拆分EnrollmentBoard的可行性
按专业拆分EnrollmentBoard是可行的,但更高效的方案是将Enrollment设为独立聚合根,搭配查询模型来快速校验约束,而非用大聚合统管所有数据。
具体实现思路:
- 每个
Enrollment聚合根对应一条选课记录,包含学生ID、课程ID、选课时间等核心字段,自身可校验"学生未重复选该课程"的不变量(通过聚合内的唯一性约束)。 - 维护两个轻量的查询模型:
CourseEnrollmentStats:仅存储每个课程的courseId、studentLimit、currentEnrollCount,用于快速校验课程容量是否超标。StudentEnrollmentStats:仅存储每个学生的studentId、enrolledCourseIds、enrollCount,用于快速校验学生选课数量是否达上限。
- 选课流程:
- 先查询两个统计模型,校验三个不变量是否满足。
- 校验通过后,创建
Enrollment聚合根并保存,同时发布StudentEnrolled领域事件。 - 异步监听事件,更新两个查询模型的统计数据。
这种方式无需加载大量聚合数据,同时通过事件异步保持查询模型的一致性。而拆分EnrollmentBoard虽然能减少单聚合数据量,但本质还是大聚合思路,灵活性和扩展性不如独立Enrollment聚合+查询模型的方案。
另外,并发场景下的竞态问题(比如同一学生同时选两门课导致超5门),可以通过数据库层面的约束兜底:给Enrollment表加(student_id, course_id)唯一约束,同时用数据库的统计查询(比如SELECT COUNT(*) FROM enrollment WHERE student_id = ?)来实时校验选课数量,确保最终一致性。
2. 同时存在两个Student模型的合理性
这种设计是合理的,本质是CQRS模式的实践:
- 作为聚合根的
Student是写模型,负责维护学生核心身份数据(姓名、年龄、邮箱)的变更,保证这类数据的事务一致性。 StudentEnrollments是读模型(或专用约束模型),仅关注与选课相关的数据,为选课场景提供高效的校验能力,不涉及学生基本信息的变更。
两者职责完全分离:写模型保证核心数据的完整性,读模型为特定业务场景提供高性能查询。只要通过领域事件同步两者的关键数据(比如学生ID变更时更新StudentEnrollments),这种设计完全符合DDD的单一职责和边界划分原则。
你可以进一步明确边界:Student聚合根只暴露身份相关的必要方法,StudentEnrollments作为独立的查询模型,由事件处理器自动维护,无需依赖Student聚合根的直接调用。
内容的提问来源于stack exchange,提问作者JackG

