Skip to main content

Modeling a business process

In contrast to tracking state of some external thing, modeling a business process aims at enforcing the proper procedure within Actyx. Not all events or changes are possible at all times, there is a prescribed sequence of events and decision points that needs to be observed. The process is usually designed by a domain expert and implemented by IT personnel.

Consider for example the agreement on what to cook for dinner:

  • someone poses the question, which comes at the price of having to list a few options
  • for a limited time period everybody gets to cast their vote
  • when it is time to make a decision, mom picks the winner
  • this happens in time for dad to pay a visit to the grocery store
  • once all ingredients are at home, the fun can begin

As a tribute to real life, we also include the possibility to abort this process at any point, which means that the family will dine out.

Solution strategy

Since following this process means limiting current choices based on what has already happened, we will use the Actyx Pond library, see the conceptual guide for an introduction. Again, we start by defining the event model for recording the progression of the workflow, but in order to ensure that only appropriate events are emitted we then use a fish to process them. This yields both a current state and a set of applicable commands.

Designing the event model

This time the event model does not record changes in some external thing; this time each event marks the progression of the desired workflow. We therefore start by drawing the workflow:

dinner

This diagram shows the five states our workflow can be in, together with the events that mark transitions between them. We include the data that will be needed and thus the event design is already finished.

type Started = { type: 'started'; options: string[] }
type Voted = { type: 'voted'; option: string }
type Decided = { type: 'decided'; choice: string; ingredients: string[] }
type Ready = { type: 'ready' }
type Aborted = { type: 'aborted' }
type DinnerEvent = Started | Voted | Decided | Ready | Aborted

Encoding the state machine

Once we have the events, we need to use them to compute a current state. This state is not just equal to the type of the most recently written event because the proper sequence of transitions needs to be observed. If we get a nonsensical event, we need to ignore it. We start out by defining the states:

type Initial = { type: 'initial' }
type Voting = { type: 'voting'; options: string[]; votes: string[] }
type Decided = { type: 'decided'; choice: string; ingredients: string[] }
type Cooking = { type: 'cooking'; choice: string }
type DineOut = { type: 'dineOut' }
type DinnerState = Initial | Voting | Decided | Cooking | DineOut

There is some overlap with the event definitions, but the states usually hold on to more information than a single event provided. For example, it is useful to know what we are cooking, or we need to keep track of submitted votes. The state machine is the code that takes an existing state and a new event and then decides what the following state shall be.

const onEvent: Reduce<DinnerState, DinnerEvent> = (state, event) => {
switch (state.type) {
case 'initial': {
switch (event.type) {
case 'started': {
const { options } = event
return { type: 'started', options }
}
}
break
}
case 'voting': {
switch (event.type) {
case 'voted': {
const { option } = event
const { votes } = state
votes.push(option)
return { ...state, votes }
}
case 'decided': {
const { choice, ingredients } = event
return { state: 'decided', choice, ingredients }
}
}
break
}
// and so on for the other states
}
// otherwise, if an unexpected event was encountered, stay in the same state
return state
}

If this state machine gets too big, it is a good idea to factor out the handling of each state into its own function.

Classes are not yet supported

Please note that states need to be plain objects in current versions of Actyx Pond — using classes is not yet supported.

Breathing life into the fish

In order to see the state machine in action, we will need to instantiate it in an Actyx Pond. This requires a small collection of metadata to describe what exactly should happen:

const DinnerTag = Tag<DinnerEvent>('Dinner')
const DinnerFish = (dinnerId: string): Fish<DinnerState, DinnerEvent> => {
fishId: FishId.of('Dinner', dinnerId, 1),
where: DinnerTag.withId(dinnerId),
initialState: { type: 'initial' },
onEvent
}

Now we could observe this fish with pond.observe(DinnerFish('abcd')), but it will always be in the initial state because no events have been emitted.

Offering the appropriate commands

Each of the state transitions described above is triggered by the reception of an event, which may come from some other Actyx node or from the local one. In any case it must be generated somewhere, based on the current state known at that Actyx node. This is done using the Pond.run() facility.

const commands = (dinnerId: string, pond: Pond) => (state: DinnerState) => {
start: state.type === 'initial'
? (options: string[]) =>
pond
.run(
(state2, enqueue) =>
state2.type === 'initial' &&
enqueue([
DinnerTag.withId(dinnerId).and(Tag('started')),
{ type: 'started', options },
]),
)
.toPromise()
: undefined
vote: state.type === 'voting'
? (option: string) =>
pond
.run(
(state2, enqueue) =>
state2.type === 'voting' &&
enqueue([DinnerTag.withId(dinnerId), { type: 'voted', option }]),
)
.toPromise()
: undefined
// and so on for the other events
}

This function needs to be primed with a dinnerId and pond, which results in a function that computes the available commands for a given state. A (graphical) UI could inspect the returned object and render buttons according to command availability. When such a button is pressed, the required inputs are passed into the corresponding command function, which will dispatch this request via the Pond to the DinnerFish. If the target fish still is in the right state when it is this command’s turn to be executed, then the enqueue function is invoked to effect the event emission. Subsequently the fish will see the emitted event and publish a new state to all its observers.

Await the promises

The Promise returned from each command invocation should be awaited to see whether the event publication succeeded: it can fail if Actyx is currently unavailable (for example because it is being restarted).

Tying the pieces together

With the above code implemented, we can create a skeleton “dinner voting app”:

import * as uuid from 'uuid'

const pond = await Pond.default(/* app manifest */)
const dinnerId = uuid.v4()
const dinnerCommands = commands(dinnerId, pond)
pond.observe(DinnerFish(dinnerId), (state) => renderButtons(dinnerCommands(state)))

This is not quite good enough yet because every time this app starts on some Actyx node it will show a fresh dinner workflow in its initial state. Instead, we want to show a selection of dinner preparations to join. The astute reader will have noticed that the extra started tag has been added in the command processor defined above. Using this tag we can efficiently find all preparations started within the last hours:

const cutOff = new Date(Date.now() - 6 * 60 * 60 * 1000) // six hours ago
const dinnerIdResult = await pond.events().queryAql({
query: `PRAGMA features := timeRange
FROM 'Dinner' & 'started' & from(${cutOff})`,
})
const dinnerIds = dinnerIdResult
.filter((r) => r.type === 'event')
.map((r) => r.meta.tags.find((t) => t.startsWith('Dinner:')?.slice(7)))
const dinnerStates = await Promise.all(
dinnerIds.map(
(id) =>
new Promise((res) => {
const cancel = pond.observe(
DinnerFish(id),
(state) => {
cancel()
res({ ...state, id })
},
() => cancel(),
)
}),
),
)

Now we have a set of dinner workflows, complete with their identifier and respective state. We could render an overview page for the user to select one of these or start a new one (i.e. creating a new identifier and emitting a started event with the corresponding fish).

How it will be executed

The process we described and encoded in a state machine relies on getting the events in the right order: if we see a ready event without having seen the decided event then we have no clue what we should be cooking! Actyx helps by giving you three core guarantees:

  • every node will eventually get all the events it needs (assuming the system keeps running and communication works sometimes) [reliability]
  • once a node gets all events about some process, it will apply them to the state machine in the same order as all other nodes [eventual consistency]
  • an event that is emitted after seeing a state based on some other events will be ordered after those other events [causality]

In our above process, dad will have seen the decided event (and the included list of ingredients) when he publishes the ready event. Therefore, everyone will eventually see these two in that same order. One of the kids may first get dad’s event without having synced events from mom’s phone yet, in which case our onEvent function will ignore the ready event; the app will remain in the voting state until the sync with mom occurs.

There is one piece of our event design that bears special mention: we included the choice in the decided event instead of relying on always applying the same voting mechanism. This is important because the decision recorded here is fixed, based on what mom knew at the time. Once dad is shopping groceries there is no way to go back and revisit the voting process. So if a vote arrives late at mom’s phone, subsequent review of the event history may indicate that the decision was “incorrect”, but that is only true in hindsight and irrelevant for the ongoing execution of the workflow.