Back to the Future — Camunda Task UI mediation done right

Jan Galinski
Holisticon Consultants
4 min readOct 26, 2023
The example process: start the process through a controller and return the user task form, although business logic is executed in between.

Ever-so-often when I build process applications for my customers projects, I am confronted with the same requirement: One the one hand, we want to model complex business logic flows as BPMN files and let camunda control the execution, including asynchronous continuations, external workers, event subscriptions and so on.
On the other hand, from the users perspective, the interaction should feel like a synchronous, wizard like screen flow.

For years, we implemented this behavior using polling and timeouts, as described in this almost 12 year old, but still relevant article: “Page-Flow vs. Process-Flow — how a UI Mediator might help”.

Though this is a working solution that proofed its value, there is quite a lot to consider and implement, both on the web client- and on the process application-side.

So there has to be a better solution. And there is: Register your controller for the desired process state (in the simpliest case: the next user task is created) and retrieve a CompletableFuture in return. Then use a delegate expression listener on an activity to complete the future, thus continuing the synchronous execution of the controller logic and return the desired result.

Take the above BPMN diagram: We want to start a process (by clicking some button for example) and directly start working on the user task, so it feels like we are just navigating through a SPA flow.

But: There is some expensive heavy business logic that has to be executed before the user task is ready to work on. This logic could (as mentioned above) also involve external task workers or message communication between distributed systems. In our simple case here, we just hid a timer that waits for 5 seconds inside a collapsed embedded sub process:

Our “expensive long running business logic” hidden inside the sub process

Let’s have a look at the implementation in detail.

Step 1

Start a new process instance and register a CompletableFuture. Instead of a ProcessInstance or its Id , we return this future to the controller.

private val futures = ConcurrentHashMap<BusinessKey, CompletableFuture<TaskId>>()

fun startProcess(businessKey: BusinessKey): CompletableFuture<TaskId> {
val future = futures.computeIfAbsent(businessKey) { CompletableFuture() }
runtimeService.startProcessInstanceByMessage(MSG_START, businessKey)
return future
}

We need a “registry” for the future, so the async process execution can access it later.

Step 2

Use a TaskListener on creation of the UserTask to complete the future:

Alternative TaskListener implementation: A field on the process backing bean, referenced via SPEL.

val onTaskCreation = TaskListener {
futures.remove(it.execution.businessKey)?.complete(it.id)
}

So no matter how long the process needs to reach the state we are interested in, it will be able to look up the future on the registry, remove it (it has fulfilled its duty) and complete it.

Step 3

In the controller, keep the future and join() to block the controller thread while the process is executing. Then return the creation URI for the task form:

@PostMapping
fun startProcess(@RequestParam businessKey: BusinessKey): ResponseEntity<Unit> {
val taskId = process.startProcess(businessKey).join()
return ResponseEntity
.created(URI.create("/camunda/app/tasklist/default/#/?task=$taskId"))
.build()
}

And voila: we are done:

This worked as we hoped for: we get a `201 CREATED` with the location we need to open the task in the camuda tasklist
Our task in the camunda tasklist, just click: Complete and you are done.

Summary

We just saw a simple, effective way to block the controller until we reached a desired state in the process. There was no need to provide a query endpoint for the UserTask and implement a polling or retry loop in the frontend to continue the UI flow. The key element is the future registry, which allows the synchronous controller flow and the asynchronous process flow to share the same CompletableFuture instance and thus notify each other on when something is done.

Discussion

Two things quickly come to mind: First: this mediation relies on an in-memory registry. What if the application fails during execution of business logic, what if the system goes offline, what if we loose the registry or try to re-execute the same instance?
Well, these are always tricky scenarios that have to be discussed carefully, BUT: I strongly believe, that this future based UI mediation does not supply any additional risk here.
Either the business logic before the UserTask fails … then the target state is never reached and you will need to handle the incident, no traditional polling would help you to continue.
Or the business logic is successful, but something with registering/accessing the future went wrong, or you try to access a future that has already been completed … In this case, you still have a persisted defined state in the camunda database and can easily recover by just opening the task in your tasklist to continue.

In any case, it is of course a good idea to define a timeout on future completion, so you do not keep the client waiting forever. Or, even better, consider returning the future directly, so your UI client can wait for the completion. Even switching to webflux/SSE and just pushing the correct state to the client is possible without much additional coding, but that is out of the scope of this example.

As always, you can read the full example source code in my Github-repository, please feel free to play around and provide improvements, or share your thoughts and questions in the comment section.

--

--

Jan Galinski
Holisticon Consultants

I am a Senior Consultant/Software Engineer at Holisticon AG, based in Hamburg, Germany, focussing on event-driven microservices and process automation.