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 10 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 10 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 student subscribed to course:

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:

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function CourseDefined({ courseId, capacity }) {
  return {
    type: "CourseDefined",
    data: { courseId, capacity },
    tags: [`course:${courseId}`],
  }
}

// projections for decision models:

function CourseExistsProjection(courseId) {
  return createProjection({
    initialState: false,
    handlers: {
      CourseDefined: (state, event) => true,
    },
    tagFilter: [`course:${courseId}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  defineCourse(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
    })
    if (state.courseExists) {
      throw new Error(`Course with id "${command.courseId}" already exists`)
    }
    this.eventStore.append(
      CourseDefined({
        courseId: command.courseId,
        capacity: command.capacity,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Define course with existing id",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":10}),
      ],
    },
    when: {
      command: {
        type: "defineCourse",
        data: {"courseId":"c1","capacity":15},
      }
    },
    then: {
      expectedError: "Course with id \"c1\" already exists",
    }
  }, 
  {
    description: "Define course with new id",
    when: {
      command: {
        type: "defineCourse",
        data: {"courseId":"c1","capacity":15},
      }
    },
    then: {
      expectedEvent: CourseDefined({"courseId":"c1","capacity":15}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function CourseDefined({
  courseId,
  capacity,
} : {
  courseId: string,
  capacity: number,
}) {
  return {
    type: "CourseDefined" as const,
    data: { courseId, capacity },
    tags: [`course:${courseId}`],
  }
}

type EventTypes = ReturnType<typeof CourseDefined>

// projections for decision models:

function CourseExistsProjection(courseId: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      CourseDefined: (state, event) => true,
    },
    tagFilter: [`course:${courseId}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  defineCourse(command: { courseId: string; capacity: number }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
    })
    if (state.courseExists) {
      throw new Error(`Course with id "${command.courseId}" already exists`)
    }
    this.eventStore.append(
      CourseDefined({
        courseId: command.courseId,
        capacity: command.capacity,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

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:

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function CourseDefined({ courseId, capacity }) {
  return {
    type: "CourseDefined",
    data: { courseId, capacity },
    tags: [`course:${courseId}`],
  }
}

function CourseCapacityChanged({ courseId, newCapacity }) {
  return {
    type: "CourseCapacityChanged",
    data: { courseId, newCapacity },
    tags: [`course:${courseId}`],
  }
}

// projections for decision models:

function CourseExistsProjection(courseId) {
  return createProjection({
    initialState: false,
    handlers: {
      CourseDefined: (state, event) => true,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function CourseCapacityProjection(courseId) {
  return createProjection({
    initialState: 0,
    handlers: {
      CourseDefined: (state, event) => event.data.capacity,
      CourseCapacityChanged: (state, event) => event.data.newCapacity,
    },
    tagFilter: [`course:${courseId}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  defineCourse(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
    })
    if (state.courseExists) {
      throw new Error(`Course with id "${command.courseId}" already exists`)
    }
    this.eventStore.append(
      CourseDefined({
        courseId: command.courseId,
        capacity: command.capacity,
      }),
      appendCondition
    )
  }

  changeCourseCapacity(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
      courseCapacity: CourseCapacityProjection(command.courseId),
    })
    if (!state.courseExists) {
      throw new Error(`Course "${command.courseId}" does not exist`)
    }
    if (state.courseCapacity === command.newCapacity) {
      throw new Error(`New capacity ${command.newCapacity} is the same as the current capacity`)
    }
    this.eventStore.append(
      CourseCapacityChanged({
        courseId: command.courseId,
        newCapacity: command.newCapacity,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Define course with existing id",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":10}),
      ],
    },
    when: {
      command: {
        type: "defineCourse",
        data: {"courseId":"c1","capacity":15},
      }
    },
    then: {
      expectedError: "Course with id \"c1\" already exists",
    }
  }, 
  {
    description: "Define course with new id",
    when: {
      command: {
        type: "defineCourse",
        data: {"courseId":"c1","capacity":15},
      }
    },
    then: {
      expectedEvent: CourseDefined({"courseId":"c1","capacity":15}),
    }
  }, 
  {
    description: "Change capacity of a non-existing course",
    when: {
      command: {
        type: "changeCourseCapacity",
        data: {"courseId":"c0","newCapacity":15},
      }
    },
    then: {
      expectedError: "Course \"c0\" does not exist",
    }
  }, 
  {
    description: "Change capacity of a course to a new value",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":12}),
      ],
    },
    when: {
      command: {
        type: "changeCourseCapacity",
        data: {"courseId":"c1","newCapacity":15},
      }
    },
    then: {
      expectedEvent: CourseCapacityChanged({"courseId":"c1","newCapacity":15}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function CourseDefined({
  courseId,
  capacity,
} : {
  courseId: string,
  capacity: number,
}) {
  return {
    type: "CourseDefined" as const,
    data: { courseId, capacity },
    tags: [`course:${courseId}`],
  }
}

function CourseCapacityChanged({
  courseId,
  newCapacity,
} : {
  courseId: string,
  newCapacity: number,
}) {
  return {
    type: "CourseCapacityChanged" as const,
    data: { courseId, newCapacity },
    tags: [`course:${courseId}`],
  }
}

type EventTypes = ReturnType<
  | typeof CourseDefined,
  | typeof CourseCapacityChanged,
>

// projections for decision models:

function CourseExistsProjection(courseId: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      CourseDefined: (state, event) => true,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function CourseCapacityProjection(courseId: string) {
  return createProjection<EventTypes, number>({
    initialState: 0,
    handlers: {
      CourseDefined: (state, event) => event.data.capacity,
      CourseCapacityChanged: (state, event) => event.data.newCapacity,
    },
    tagFilter: [`course:${courseId}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  defineCourse(command: { courseId: string; capacity: number }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
    })
    if (state.courseExists) {
      throw new Error(`Course with id "${command.courseId}" already exists`)
    }
    this.eventStore.append(
      CourseDefined({
        courseId: command.courseId,
        capacity: command.capacity,
      }),
      appendCondition
    )
  }

  changeCourseCapacity(command: { studentId: string; newCapacity: number }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
      courseCapacity: CourseCapacityProjection(command.courseId),
    })
    if (!state.courseExists) {
      throw new Error(`Course "${command.courseId}" does not exist`)
    }
    if (state.courseCapacity === command.newCapacity) {
      throw new Error(`New capacity ${command.newCapacity} is the same as the current capacity`)
    }
    this.eventStore.append(
      CourseCapacityChanged({
        courseId: command.courseId,
        newCapacity: command.newCapacity,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

Feature 3: Subscribe student to course

The last implementation contains the core example that requires constraint checks across multiple entities, adding a subscribeStudentToCourse command with a corresponding handler 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
Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function CourseDefined({ courseId, capacity }) {
  return {
    type: "CourseDefined",
    data: { courseId, capacity },
    tags: [`course:${courseId}`],
  }
}

function CourseCapacityChanged({ courseId, newCapacity }) {
  return {
    type: "CourseCapacityChanged",
    data: { courseId, newCapacity },
    tags: [`course:${courseId}`],
  }
}

function StudentSubscribedToCourse({ studentId, courseId }) {
  return {
    type: "StudentSubscribedToCourse",
    data: { studentId, courseId },
    tags: [`student:${studentId}`, `course:${courseId}`],
  }
}

// projections for decision models:

function CourseExistsProjection(courseId) {
  return createProjection({
    initialState: false,
    handlers: {
      CourseDefined: (state, event) => true,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function CourseCapacityProjection(courseId) {
  return createProjection({
    initialState: 0,
    handlers: {
      CourseDefined: (state, event) => event.data.capacity,
      CourseCapacityChanged: (state, event) => event.data.newCapacity,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function StudentAlreadySubscribedProjection(studentId, courseId) {
  return createProjection({
    initialState: false,
    handlers: {
      StudentSubscribedToCourse: (state, event) => true,
    },
    tagFilter: [`student:${studentId}`, `course:${courseId}`],
  })
}

function NumberOfCourseSubscriptionsProjection(courseId) {
  return createProjection({
    initialState: 0,
    handlers: {
      StudentSubscribedToCourse: (state, event) => state + 1,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function NumberOfStudentSubscriptionsProjection(studentId) {
  return createProjection({
    initialState: 0,
    handlers: {
      StudentSubscribedToCourse: (state, event) => state + 1,
    },
    tagFilter: [`student:${studentId}`],
  })
}

// command handlers:

class Api {
  eventStore
  constructor(eventStore) {
    this.eventStore = eventStore
  }

  defineCourse(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
    })
    if (state.courseExists) {
      throw new Error(`Course with id "${command.courseId}" already exists`)
    }
    this.eventStore.append(
      CourseDefined({
        courseId: command.courseId,
        capacity: command.capacity,
      }),
      appendCondition
    )
  }

  changeCourseCapacity(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
      courseCapacity: CourseCapacityProjection(command.courseId),
    })
    if (!state.courseExists) {
      throw new Error(`Course "${command.courseId}" does not exist`)
    }
    if (state.courseCapacity === command.newCapacity) {
      throw new Error(`New capacity ${command.newCapacity} is the same as the current capacity`)
    }
    this.eventStore.append(
      CourseCapacityChanged({
        courseId: command.courseId,
        newCapacity: command.newCapacity,
      }),
      appendCondition
    )
  }

  subscribeStudentToCourse(command) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
      courseCapacity: CourseCapacityProjection(command.courseId),
      numberOfCourseSubscriptions: NumberOfCourseSubscriptionsProjection(command.courseId),
      numberOfStudentSubscriptions: NumberOfStudentSubscriptionsProjection(command.studentId),
      studentAlreadySubscribed: StudentAlreadySubscribedProjection(command.studentId, command.courseId),
    })
    if (!state.courseExists) {
      throw new Error(`Course "${command.courseId}" does not exist`)
    }
    if (state.numberOfCourseSubscriptions >= state.courseCapacity) {
      throw new Error(`Course "${command.courseId}" is already fully booked`)
    }
    if (state.studentAlreadySubscribed) {
      throw new Error("Student already subscribed to this course")
    }
    if (state.numberOfStudentSubscriptions >= 5) {
      throw new Error("Student already subscribed to 5 courses")
    }
    this.eventStore.append(
      StudentSubscribedToCourse({
        studentId: command.studentId,
        courseId: command.courseId,
      }),
      appendCondition
    )
  }
}

// test cases:

const eventStore = new InMemoryDcbEventStore()
const api = new Api(eventStore)
runTests(api, eventStore, [
  {
    description: "Define course with existing id",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":10}),
      ],
    },
    when: {
      command: {
        type: "defineCourse",
        data: {"courseId":"c1","capacity":15},
      }
    },
    then: {
      expectedError: "Course with id \"c1\" already exists",
    }
  }, 
  {
    description: "Define course with new id",
    when: {
      command: {
        type: "defineCourse",
        data: {"courseId":"c1","capacity":15},
      }
    },
    then: {
      expectedEvent: CourseDefined({"courseId":"c1","capacity":15}),
    }
  }, 
  {
    description: "Change capacity of a non-existing course",
    when: {
      command: {
        type: "changeCourseCapacity",
        data: {"courseId":"c0","newCapacity":15},
      }
    },
    then: {
      expectedError: "Course \"c0\" does not exist",
    }
  }, 
  {
    description: "Change capacity of a course to a new value",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":12}),
      ],
    },
    when: {
      command: {
        type: "changeCourseCapacity",
        data: {"courseId":"c1","newCapacity":15},
      }
    },
    then: {
      expectedEvent: CourseCapacityChanged({"courseId":"c1","newCapacity":15}),
    }
  }, 
  {
    description: "Subscribe student to non-existing course",
    when: {
      command: {
        type: "subscribeStudentToCourse",
        data: {"studentId":"s1","courseId":"c0"},
      }
    },
    then: {
      expectedError: "Course \"c0\" does not exist",
    }
  }, 
  {
    description: "Subscribe student to fully booked course",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":3}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c1"}),
        StudentSubscribedToCourse({"studentId":"s2","courseId":"c1"}),
        StudentSubscribedToCourse({"studentId":"s3","courseId":"c1"}),
      ],
    },
    when: {
      command: {
        type: "subscribeStudentToCourse",
        data: {"studentId":"s4","courseId":"c1"},
      }
    },
    then: {
      expectedError: "Course \"c1\" is already fully booked",
    }
  }, 
  {
    description: "Subscribe student to the same course twice",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":10}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c1"}),
      ],
    },
    when: {
      command: {
        type: "subscribeStudentToCourse",
        data: {"studentId":"s1","courseId":"c1"},
      }
    },
    then: {
      expectedError: "Student already subscribed to this course",
    }
  }, 
  {
    description: "Subscribe student to more than 5 courses",
    given: {
      events: [
        CourseDefined({"courseId":"c6","capacity":10}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c1"}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c2"}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c3"}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c4"}),
        StudentSubscribedToCourse({"studentId":"s1","courseId":"c5"}),
      ],
    },
    when: {
      command: {
        type: "subscribeStudentToCourse",
        data: {"studentId":"s1","courseId":"c6"},
      }
    },
    then: {
      expectedError: "Student already subscribed to 5 courses",
    }
  }, 
  {
    description: "Subscribe student to course with capacity",
    given: {
      events: [
        CourseDefined({"courseId":"c1","capacity":10}),
      ],
    },
    when: {
      command: {
        type: "subscribeStudentToCourse",
        data: {"studentId":"s1","courseId":"c1"},
      }
    },
    then: {
      expectedEvent: StudentSubscribedToCourse({"studentId":"s1","courseId":"c1"}),
    }
  }, 
])

Info

This example uses composed projections to build Decision Models (explore library source code )

// event type definitions:

function CourseDefined({
  courseId,
  capacity,
} : {
  courseId: string,
  capacity: number,
}) {
  return {
    type: "CourseDefined" as const,
    data: { courseId, capacity },
    tags: [`course:${courseId}`],
  }
}

function CourseCapacityChanged({
  courseId,
  newCapacity,
} : {
  courseId: string,
  newCapacity: number,
}) {
  return {
    type: "CourseCapacityChanged" as const,
    data: { courseId, newCapacity },
    tags: [`course:${courseId}`],
  }
}

function StudentSubscribedToCourse({
  studentId,
  courseId,
} : {
  studentId: string,
  courseId: string,
}) {
  return {
    type: "StudentSubscribedToCourse" as const,
    data: { studentId, courseId },
    tags: [`student:${studentId}`, `course:${courseId}`],
  }
}

type EventTypes = ReturnType<
  | typeof CourseDefined,
  | typeof CourseCapacityChanged,
  | typeof StudentSubscribedToCourse,
>

// projections for decision models:

function CourseExistsProjection(courseId: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      CourseDefined: (state, event) => true,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function CourseCapacityProjection(courseId: string) {
  return createProjection<EventTypes, number>({
    initialState: 0,
    handlers: {
      CourseDefined: (state, event) => event.data.capacity,
      CourseCapacityChanged: (state, event) => event.data.newCapacity,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function StudentAlreadySubscribedProjection(studentId: string, courseId: string) {
  return createProjection<EventTypes, boolean>({
    initialState: false,
    handlers: {
      StudentSubscribedToCourse: (state, event) => true,
    },
    tagFilter: [`student:${studentId}`, `course:${courseId}`],
  })
}

function NumberOfCourseSubscriptionsProjection(courseId: string) {
  return createProjection<EventTypes, number>({
    initialState: 0,
    handlers: {
      StudentSubscribedToCourse: (state, event) => state + 1,
    },
    tagFilter: [`course:${courseId}`],
  })
}

function NumberOfStudentSubscriptionsProjection(studentId: string) {
  return createProjection<EventTypes, number>({
    initialState: 0,
    handlers: {
      StudentSubscribedToCourse: (state, event) => state + 1,
    },
    tagFilter: [`student:${studentId}`],
  })
}

// command handlers:

class Api {
  constructor(private eventStore: EventStore) {}

  defineCourse(command: { courseId: string; capacity: number }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
    })
    if (state.courseExists) {
      throw new Error(`Course with id "${command.courseId}" already exists`)
    }
    this.eventStore.append(
      CourseDefined({
        courseId: command.courseId,
        capacity: command.capacity,
      }),
      appendCondition
    )
  }

  changeCourseCapacity(command: { studentId: string; newCapacity: number }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
      courseCapacity: CourseCapacityProjection(command.courseId),
    })
    if (!state.courseExists) {
      throw new Error(`Course "${command.courseId}" does not exist`)
    }
    if (state.courseCapacity === command.newCapacity) {
      throw new Error(`New capacity ${command.newCapacity} is the same as the current capacity`)
    }
    this.eventStore.append(
      CourseCapacityChanged({
        courseId: command.courseId,
        newCapacity: command.newCapacity,
      }),
      appendCondition
    )
  }

  subscribeStudentToCourse(command: { studentId: string; courseId: string }) {
    const { state, appendCondition } = buildDecisionModel(this.eventStore, {
      courseExists: CourseExistsProjection(command.courseId),
      courseCapacity: CourseCapacityProjection(command.courseId),
      numberOfCourseSubscriptions: NumberOfCourseSubscriptionsProjection(command.courseId),
      numberOfStudentSubscriptions: NumberOfStudentSubscriptionsProjection(command.studentId),
      studentAlreadySubscribed: StudentAlreadySubscribedProjection(command.studentId, command.courseId),
    })
    if (!state.courseExists) {
      throw new Error(`Course "${command.courseId}" does not exist`)
    }
    if (state.numberOfCourseSubscriptions >= state.courseCapacity) {
      throw new Error(`Course "${command.courseId}" is already fully booked`)
    }
    if (state.studentAlreadySubscribed) {
      throw new Error("Student already subscribed to this course")
    }
    if (state.numberOfStudentSubscriptions >= 5) {
      throw new Error("Student already subscribed to 5 courses")
    }
    this.eventStore.append(
      StudentSubscribedToCourse({
        studentId: command.studentId,
        courseId: command.courseId,
      }),
      appendCondition
    )
  }
}
Experimental: 3rd party library

These Given/When/Then scenarios are visualized using an unofficial, work-in-progress, library

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