Skip to content

Example: Command handler

Paul Grimshaw edited this page Feb 28, 2025 · 7 revisions

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:

Event to be stored

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

Decision model

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")

Command handler:

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.

Multiple DecisionModels

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.

Clone this wiki locally