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
:
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