Skip to content

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:

course subscriptions example

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 ItemEvent TypesTags
courseCourseDefinedCourseId:{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 ItemEvent TypesTags
courseCourseDefinedCourseId:{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 ItemEvent TypesTags
courseCourseCapacityChanged, CourseDefinedCourseId:{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 ItemEvent TypesTags
courseCourseDefinedCourseId:{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 ItemEvent TypesTags
courseCourseCapacityChanged, CourseDefinedCourseId:{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 ItemEvent TypesTags
courseCourseCapacityChanged, CourseDefined, StudentSubscribedToCourseCourseId:{courseId}
studentStudentSubscribedToCourseStudentId:{studentId}
alreadySubscribedStudentSubscribedToCourseStudentId:{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