Course subscription example
The following example showcases the imagined application from Sara Pellegrini's blog post "Killing the Aggregate"
Challenge
The goal is an application that allows students to subscribe to courses, with the following hard constraints:
- A course cannot accept more than N students
- N, the course capacity, can change at any time to any positive integer different from the current one
- The student cannot join more than 5 courses
Traditional approaches
The first and last constraints, in particular, make this example difficult to implement using traditional Event Sourcing, as they cause the student subscribed to course Event to impact two separate entities, each with its own constraints.
There are several potential strategies to solve this without DCB:
-
Eventual consistency: Turn one of the invariants into a soft constraint, i.e. use the Read Model for verification and accept the fact that there might be overbooked courses and/or students with more than 5 subscriptions
This is of course a potential solution, with or without DCB, but it falls outside the scope of these examples
-
Larger Aggregate: Create an Aggregate that spans course and student subscriptions
This is not a viable solution because it leads to huge Aggregates and restricts parallel bookings
-
Reservation Pattern: Create an Aggregate for each, courses and students, enforcing their constraints and use a Saga to coordinate them
This works, but it leads to a lot of complexity and potentially invalid states for a period of time
DCB approach
With DCB the challenge can be solved simply by adding a Tag for each, the affected course and student to the StudentSubscribedToCourse Event:
Feature 1: Register courses
The first implementation just allows to specify new courses and make sure that they have a unique id:
model "Course subscriptions"
// Types
tag type CourseId = string
// Events
event CourseDefined { courseId: CourseId, capacity: integer }
// Entities
entity Course {
lifecycle exists
exists = CourseExists
}
// Projections
projection CourseExists(courseId: CourseId): boolean = false {
on CourseDefined => set true
}
// Commands
command DefineCourse(courseId: CourseId, capacity: integer) {
read course = Course[courseId]
require course.exists is false
emit CourseDefined { courseId, capacity }
scenario "Define course with existing id" {
given CourseDefined { courseId: "c1", capacity: 10 }
when DefineCourse { courseId: "c1", capacity: 15 }
then rejected by course.exists is false saw true
}
scenario "Define course with new id" {
when DefineCourse { courseId: "c1", capacity: 15 }
then CourseDefined { courseId: "c1", capacity: 15 }
}
}
DefineCourse reads 1 type, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
course | CourseDefined | CourseId:{courseId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: CourseId:{courseId}
Feature 2: Change course capacity
The second implementation extends the first by a ChangeCourseCapacity command that allows to change the maximum number of seats for a given course:
model "Course subscriptions"
// Types
tag type CourseId = string
// Events
event CourseDefined { courseId: CourseId, capacity: integer }
event CourseCapacityChanged { courseId: CourseId, newCapacity: integer }
// Entities
entity Course {
lifecycle exists
exists = CourseExists
capacity = CourseCapacity
}
// Projections
projection CourseExists(courseId: CourseId): boolean = false {
on CourseDefined => set true
}
projection CourseCapacity(courseId: CourseId): integer = 0 {
on CourseDefined => set event.data.capacity
on CourseCapacityChanged => set event.data.newCapacity
}
// Commands
command DefineCourse(courseId: CourseId, capacity: integer) {
read course = Course[courseId]
require course.exists is false
emit CourseDefined { courseId, capacity }
scenario "Define course with existing id" {
given CourseDefined { courseId: "c1", capacity: 10 }
when DefineCourse { courseId: "c1", capacity: 15 }
then rejected by course.exists is false saw true
}
scenario "Define course with new id" {
when DefineCourse { courseId: "c1", capacity: 15 }
then CourseDefined { courseId: "c1", capacity: 15 }
}
}
command ChangeCourseCapacity(courseId: CourseId, newCapacity: integer) {
read course = Course[courseId]
require course.exists is true
require course.capacity != newCapacity
emit CourseCapacityChanged { courseId, newCapacity }
scenario "Change capacity of a non-existing course" {
when ChangeCourseCapacity { courseId: "c0", newCapacity: 15 }
then rejected by course.exists is true saw false
}
scenario "Change capacity of a course to a new value" {
given CourseDefined { courseId: "c1", capacity: 12 }
when ChangeCourseCapacity { courseId: "c1", newCapacity: 15 }
then CourseCapacityChanged { courseId: "c1", newCapacity: 15 }
}
}
DefineCourse reads 1 type, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
course | CourseDefined | CourseId:{courseId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: CourseId:{courseId}
ChangeCourseCapacity reads 2 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
course | CourseCapacityChanged, CourseDefined | CourseId:{courseId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: CourseId:{courseId}
Feature 3: Subscribe student to course
The last implementation contains the core example that requires constraint checks across multiple entities, adding a SubscribeStudentToCourse command that checks...
- ...whether the course with the specified id exists
- ...whether the specified course still has available seats
- ...whether the student with the specified id is not yet subscribed to given course
- ...whether the student is not subscribed to more than 5 courses already
The "Consistency boundary" tab shows the resulting Query: it combines Query Items for Events tagged with the course (CourseId:{courseId}) and with the student (StudentId:{studentId}). Because the appended StudentSubscribedToCourse Event carries both Tags, a concurrent subscription to the same course or by the same student makes the AppendCondition fail:
model "Course subscriptions"
// Types
tag type CourseId = string
tag type StudentId = string
// Events
event CourseDefined { courseId: CourseId, capacity: integer }
event CourseCapacityChanged { courseId: CourseId, newCapacity: integer }
event StudentSubscribedToCourse { studentId: StudentId, courseId: CourseId }
// Entities
entity Course {
lifecycle exists
exists = CourseExists
capacity = CourseCapacity
subscriptionCount = CourseSubscriptionCount
}
entity Student {
subscriptionCount = StudentSubscriptionCount
}
// Projections
projection CourseExists(courseId: CourseId): boolean = false {
on CourseDefined => set true
}
projection CourseCapacity(courseId: CourseId): integer = 0 {
on CourseDefined => set event.data.capacity
on CourseCapacityChanged => set event.data.newCapacity
}
projection CourseSubscriptionCount(courseId: CourseId): integer = 0 {
on StudentSubscribedToCourse => increment 1
}
projection StudentSubscriptionCount(studentId: StudentId): integer = 0 {
on StudentSubscribedToCourse => increment 1
}
projection StudentAlreadySubscribed(studentId: StudentId, courseId: CourseId): boolean = false {
on StudentSubscribedToCourse => set true
}
// Commands
command DefineCourse(courseId: CourseId, capacity: integer) {
read course = Course[courseId]
require course.exists is false
emit CourseDefined { courseId, capacity }
scenario "Define course with existing id" {
given CourseDefined { courseId: "c1", capacity: 10 }
when DefineCourse { courseId: "c1", capacity: 15 }
then rejected by course.exists is false saw true
}
scenario "Define course with new id" {
when DefineCourse { courseId: "c1", capacity: 15 }
then CourseDefined { courseId: "c1", capacity: 15 }
}
}
command ChangeCourseCapacity(courseId: CourseId, newCapacity: integer) {
read course = Course[courseId]
require course.exists is true
require course.capacity != newCapacity
emit CourseCapacityChanged { courseId, newCapacity }
scenario "Change capacity of a non-existing course" {
when ChangeCourseCapacity { courseId: "c0", newCapacity: 15 }
then rejected by course.exists is true saw false
}
scenario "Change capacity of a course to a new value" {
given CourseDefined { courseId: "c1", capacity: 12 }
when ChangeCourseCapacity { courseId: "c1", newCapacity: 15 }
then CourseCapacityChanged { courseId: "c1", newCapacity: 15 }
}
}
command SubscribeStudentToCourse(studentId: StudentId, courseId: CourseId) {
read course = Course[courseId]
read student = Student[studentId]
read alreadySubscribed = StudentAlreadySubscribed(studentId, courseId)
require course.exists is true
require course.subscriptionCount < course.capacity
require alreadySubscribed is false
require student.subscriptionCount < 5
emit StudentSubscribedToCourse { studentId, courseId }
scenario "Subscribe student to non-existing course" {
when SubscribeStudentToCourse { studentId: "s1", courseId: "c0" }
then rejected by course.exists is true saw false
}
scenario "Subscribe student to fully booked course" {
given CourseDefined { courseId: "c1", capacity: 3 }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c1" }
given StudentSubscribedToCourse { studentId: "s2", courseId: "c1" }
given StudentSubscribedToCourse { studentId: "s3", courseId: "c1" }
when SubscribeStudentToCourse { studentId: "s4", courseId: "c1" }
then rejected by course.subscriptionCount < course.capacity saw 3, 3
}
scenario "Subscribe student to the same course twice" {
given CourseDefined { courseId: "c1", capacity: 10 }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c1" }
when SubscribeStudentToCourse { studentId: "s1", courseId: "c1" }
then rejected by alreadySubscribed is false saw true
}
scenario "Subscribe student to more than 5 courses" {
given CourseDefined { courseId: "c6", capacity: 10 }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c1" }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c2" }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c3" }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c4" }
given StudentSubscribedToCourse { studentId: "s1", courseId: "c5" }
when SubscribeStudentToCourse { studentId: "s1", courseId: "c6" }
then rejected by student.subscriptionCount < 5 saw 5, 5
}
scenario "Subscribe student to course with capacity" {
given CourseDefined { courseId: "c1", capacity: 10 }
when SubscribeStudentToCourse { studentId: "s1", courseId: "c1" }
then StudentSubscribedToCourse { studentId: "s1", courseId: "c1" }
}
}
DefineCourse reads 1 type, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
course | CourseDefined | CourseId:{courseId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: CourseId:{courseId}
ChangeCourseCapacity reads 2 types, 1 tag
| Query Item | Event Types | Tags |
|---|---|---|
course | CourseCapacityChanged, CourseDefined | CourseId:{courseId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: CourseId:{courseId}
SubscribeStudentToCourse reads 3 types, 2 tags
| Query Item | Event Types | Tags |
|---|---|---|
course | CourseCapacityChanged, CourseDefined, StudentSubscribedToCourse | CourseId:{courseId} |
student | StudentSubscribedToCourse | StudentId:{studentId} |
alreadySubscribed | StudentSubscribedToCourse | StudentId:{studentId}, CourseId:{courseId} |
AppendCondition: failIfEventsMatch the Query above, after the position of the last Event read
Tags of the appended events: StudentId:{studentId}, CourseId:{courseId}
Other implementations
There is a working JavaScript/TypeScript and PHP implementation of this example
Conclusion
The course subscription example demonstrates a typical requirement to enforce consistency that affects multiple entities that are not part of the same Aggregate. This document demonstrates how easy it is to achieve that with DCB
