Skip to main content

Modular processes

· 8 min read
Sondre Gjellestad
Stacc - Team Arbeidsflyt

When making large processes with multiple process definitions, things can become a bit hard to manage. Especially when there are references to state paths in other process definitions. It can also be hard to reuse processes and start multiple instances of them within a single flow in a way that doesn't cause collisions.

For those reasons, we are introducing a new model of process development where each process has its own state.

This means that all paths used in patches inside processes are isolated in the state of that specific process. Previously, subprocesses could become coupled to their parent because they shared the state. If an internal change is made in a process, there is no good way of knowing whether there are references to that state variable in other processes. By definining more explicit interfaces for input and output, we can ensure changes in one process can't affect another. This should improve maintainability and make it easier to reuse processes.

note

This is currently released as a beta feature while we gather feedback and iron out any bugs. Before you start making your subprocesses isolated, be sure to see the current limitations listed at the end.

Overview

Here is an overview of the existing model. When a subprocess is instantiated by a call activity, new processes are created internally, but they still share the same state of the overall flow.

Existing process model

In the new model, a flow is a tree of processes, each with its own state. Because each process has its own state, what used to be described by the term "flow state" is now more accurately referred to as "the state of the root process of a flow".

The main change here is that we are making the distinction of a flow and a process explicit.

Note that there is no API available for getting the state of internal processes yet.

New process model

Usage

While before you would have to map the variables flowId and _staccMeta when instantiating subprocesses with call activities, you can now simply omit them to use the new process model. Each process instance will get its own internal ID and state, but it will still be considered part of the same overall flow.

Two new functions are introduced for call activities; selectInput and map. The selectInput-function receives the state of the parent process and returns an object that will be used as input to the child process, and the map-function receives the final state of the child process once it terminates and returns a list of patches that will be applied to the parent process. Together, these two functions provide a way to control and act upon the input and output of a child process. If these functions are not defined, the child process will receive an empty input, and nothing will be mapped to the state of the parent once the child process terminates.

Attachments are completely isolated per process. Just like the output of a child process (its final state upon termination), attachments must be mapped explicitly from child to parent process if they are necessary in later stages of the parent process.

Here is a simple example:

function selectInput({ data }) {
return {
someValue: data.someValue
};
}

function map({ data, attachments }) {
return {
patch: [
{
op: "replace",
path: "/result",
value: data
}
],

// This maps all the attachments from the child process to the parent process
attachments: attachments
};
}

module.exports = {
execute,
map
};

In order to make a new modular process from scratch, make a new flow definition like you normally would and use the input to the start-handler as a way to define the interface. Any flow definition can be used as a modular process. It used to be necessary to set up variable mappings between a parent and child process to make it possible to track the relationship, this is no longer necessary. Adding a plain call activity is all that is necessary for a minimal configuration. The subprocess that is started executes its handlers as before, but each process has its own state.

When the subprocess is started, its start handler will be called with the data returned from the selectInput-function. This is opposed to the shared state model where the start handler of the subprocess would not be called at all.

Note that scope is not carried across process boundaries and must be explicitly mapped in the selectInput-function.

Processes can still share state like before, by mapping the flowId-variable. Note that the call activity selectInput and map-functions will not be executed in this case.

API

The /tasks-endpoint will now return all tasks from all processes started as part of a flow when the flowId query parameter is set.

Example

Let's exemplify this by modeling a pizza delivery process. A pizza delivery consists of two major components, making pizza, and then delivering it. First, the pizza has to be made according to specification, and second, a delivery service has to transport it to the receiver. We want it to be possible to order multiple pizzas in one order, so we make the call activity "Make pizza" a multi-instance activity.

"Order"-process

First we set up the start-handler for a flow definition called order. This flow definition is responsible for coordinating the order from the moment the customer places it, up to delivery.

// order/handlers/start.js
function execute({ input }) {
return {
// Example:
// [
// { type: "margarita", size: "large" },
// { type: "pepperoni", size: "small" }
// ]
pizzas: input.pizzas,
delivery: {
address: input.address
}
};
}

function map({ data }) {
return {
patch: [
{
op: "replace",
path: "/",
value: data
}
]
};
}

module.exports = {
execute,
map
};

Making pizza

Next, let's make a flow definition called make-pizza. This flow will be responsible for coordinating the creation of each of the ordered pizzas.

"Make pizza"-process

// make-pizza/handlers/start.js
function execute({ input }) {
return {
pizza: input.pizza
};
}

function map({ data }) {
return {
patch: [
{
op: "replace",
path: "/",
value: data
}
]
};
}

module.exports = {
execute,
map
};

From the order-flow, we add a call activity handler that provides the input to the make-pizza-flow. We use the collection-function to execute make-pizza once for every pizza in the order. The selectInput-function then takes each pizza from the scope, and assigns it as the input to the make-pizza flow. When the pizza is finished, the map-function is called with the final state of the make-pizza-flow. Note that since this call activity task is multi-instance, the map-function will be called once per element.

// order/handlers/call-activities/make-pizzas.js
function collection({ state }) {
return {
key: "pizza",
values: state.pizzas
};
}

function selectInput({ scope }) {
return {
pizza: scope.pizza
};
}

function map({ data }) {
return {
patch: [
{
op: "add",
path: `/result/pizzas/${data.pizza.id}`,
value: data
}
]
};
}

module.exports = {
collection,
selectInput,
map
};

Delivering the pizzas

Finally, once all the pizzas have been made, the delivery process is started. It gets passed the set of finished pizzas, the origin and the destination.

"Delivery"-process

Inside the delivery process, the input is mapped onto the state for further processing.

// delivery/handlers/start.js
function execute({ input }) {
return {
goods: input.goods,
origin: input.origin,
destination: input.destination
};
}

function map({ data }) {
return {
patch: [
{
op: "replace",
path: "/",
value: data
}
]
};
}

module.exports = {
execute,
map
};

And finally, we pass it the delivery details from the parent process.

// order/call-activities/deliver.js
function selectInput({ state }) {
return {
goods: Object.keys(state.result.pizzas)
origin: "Pizza street 1",
destination: state.delivery.address
};
}

function map({ data }) {
return {
patch: [
{
op: "replace",
path: "/result/delivery",
value: data
}
]
};
}

module.exports = {
selectInput,
map
};

And voilà, the pizza has been delivered!

Testing

Because the state of each process is now fully independent, it is easier to test each one whether it is being used as part of a larger process or not.

Child processes do not inherit the scenario a parent process was started with.

Limitations

Here is a list of the current limitations:

  • There is no way for a parent process to receive information from a subprocess before it has completed.
  • The state of a subprocess cannot be accessed through the API.
  • Case manager is not aware of the new process model, and therefore lacks some features. For example, the state view only shows the root process state.

Summary

There is now a new way of using subprocesses started by call activities in flows, where each process has its own state. We hope this new model of composing processes will make it easier to create and maintain complex processes, and make it easier to reuse processes in multiple contexts.