-
Notifications
You must be signed in to change notification settings - Fork 1
Example: Command handler
An example of a command handler to register a new course is outlined below in its simplest form. This involves a Decision Model to check that a Course with the same ID does not already exist prior to publishing a new event:
Let's introduce an event, implemented as a class here (class is an optional approach). It takes a courseId, title and capacity in the constructor.
export class CourseWasRegisteredEvent implements EsEvent {
public type: "courseWasRegistered" = "courseWasRegistered"
public tags: Tags
public data: { courseId: string, title: string; capacity: number }
constructor({ courseId, title, capacity }: { courseId: string; title: string; capacity: number }) {
this.tags = Tags.fromObj({ courseId }) //helper to convert object to list of strings in format `<key>=<value>`
this.data = { courseId, title, capacity }
}
}
The decision model (EventHandlerWithState) is an object that defines what events are of interest, what the default state should be and how any event seen by the handler affect that state.
This example DecisionModel is interested only in courseWasRegistered event for courses with Id 1234
. The handler has a "default state" (init) of false
(doesn't exist). Every time a courseWasRegistered is received, this state is changed to true
.
Each eventHandler function (e.g. courseWasRegistered
) receives the eventEnvelope and the current state, and returns the new state. Note here we have not used either of these parameters (in fact they could be omitted)
const courseExists = (
courseId: string
): EventHandlerWithState<{
state: boolean
tagFilter: Tags
eventHandlers: CourseWasRegisteredEvent
}> => ({
tagFilter: Tags.fromObj({ courseId: "course-1234" }),
init: false,
when: {
courseWasRegistered: (eventEvenlope, state) => true
}
})
For convenenience, we can wrap the construction of this DecisionModel in a closure, passing in the tags to filter (e.g. courseId
):
export const CourseExists = (
courseId: string
): EventHandlerWithState<{
state: boolean
tagFilter: Tags
eventHandlers: CourseWasRegisteredEvent
}> => ({
tagFilter: Tag.fromObj({ courseId }),
init: false,
when: {
courseWasRegistered: () => true
}
})
const courseExists = CourseExists("1234")
We can use our buildDecisionModel function to rebuild the DecisionModel, check its state and append the events:
const registerCourseCommandHandler = async (course: { id: string; title: string; capacity: number }) => {
const { id, title, capacity } = course
const { state, appendCondition } = await buildDecisionModel(eventStore, {
courseExists: CourseExists(id)
})
if (state.courseExists) throw new Error(`Course with id ${id} already exists`)
try {
await eventStore.append(new CourseWasRegisteredEvent({ courseId: id, title, capacity }), appendCondition)
} catch (err) {
//Check error and retry/report in case of a race condition
}
}
The buildDecisionModel
function will take care of deciding which events each handler requires, efficiently querying the event store and applying them to the state. It returns a pre-built AppendCondition that can be passed with the subsequent append
method.
In this example, the append
would fail if another user created a course with the ID course-1234
after the build of the DecisionModel but before the append.
This is one of the guarantees the library provides.
Often you will have multiple checks to do. We advise you separate each of these checks into small, granular DecisionModels each with its own dedicated purpose.
For example here is another DecisionModel to check the existing capacity of a course:
export const CourseCapacity = (
courseId: string
): EventHandlerWithState<{
state: { subscriberCount: number; capacity: number }
eventHandlers:
| CourseWasRegisteredEvent
| CourseCapacityWasChangedEvent
| StudentWasSubscribedEvent
| StudentWasUnsubscribedEvent
}> => ({
tagFilter: Tags.fromObj({ courseId }),
init: { subscriberCount: 0, capacity: 0 },
when: {
courseWasRegistered: ({ event }) => ({
capacity: event.data.capacity,
subscriberCount: 0
}),
courseCapacityWasChanged: ({ event }, { subscriberCount }) => ({
subscriberCount,
capacity: event.data.newCapacity
}),
studentWasSubscribed: (eventEnvelope, { capacity, subscriberCount }) => ({
subscriberCount: subscriberCount + 1,
capacity
}),
studentWasUnsubscribed: (eventEnvelope, { capacity, subscriberCount }) => ({
subscriberCount: subscriberCount - 1,
capacity
})
}
})
This model handles 4 different types of events, and is filtered to courseId
as well. Note each event handler receives the eventEnvelope
and the currentState
, and returns the new state.
The command handler to subscribe a student might check for both existence of the course, and to check there is remaining capacity:
const subscribeStudentToCourse: async ({ courseId, studentId }) => {
const { state, appendCondition } = await buildDecisionModel(eventStore, {
courseExists: CourseExists(courseId),
courseCapacity: CourseCapacity(courseId)
})
if (!state.courseExists) throw new Error(`Course ${courseId} doesn't exist.`)
if (state.courseCapacity.subscriberCount >= state.courseCapacity.capacity)
throw new Error(`Course ${courseId} is full.`)
try {
await eventStore.append({ courseId, studentId }), appendCondition)
} catch (err) {
//Check error and retry/report in case of a race condition
}
}
In this case the library still guarantees there will be no events that affect any of the relevant DecisionModels.