Cabinee External Task
Overview
Cabinee External Task Starter extends the External Task mechanism and adds:
- Automatic extraction of
extensionPropertiesfrom BPMN - Routing by
connector / action / version - Task dispatching via Kafka
- Result handling via Kafka
- A plugin architecture for connectors
- A Spring Boot starter with autoconfiguration
This makes it possible to build loosely coupled integration services without direct REST polling of the Process Engine.
Status on Camunda 8. The mechanism is implemented but disabled by default and is not battle-tested in production. To enable it, set
camunda-eight-client.jobs.enabled: trueand fillcamunda-eight-client.jobs.types: with an empty type list the worker ends its cycle without activating anything. The Kafka message contract and connector implementations stay the same — only BPMN modeling and how jobs are obtained from the engine differ.
Architecture
In Camunda 8, external tasks are jobs. Jobs are not pushed by subscription; they are fetched by the JobActivationWorker component inside unibpm-app via REST API v2.
Camunda 8 (Zeebe)
│
│ (job created from zeebe:taskDefinition)
▼
JobActivationWorker (unibpm-app)
│
│ 1. POST /v2/jobs/activation for each type in jobs.types
│ 2. Reads zeebe:taskHeaders -> connector/action/version
│ 3. Resolves businessKey by processInstanceKey
│ 4. Publishes a Job to Kafka
▼
Kafka Topic (jobs.{type}.{env})
│
▼
unibpm-integration-runtime
│
│ Processes the task
│ Publishes the result
▼
Kafka Topic (results)
│
▼
unibpm-app
│
▼
Camunda 8
(completion / error / failure)
If businessKey is not yet resolved at activation time, the job is returned to the engine via failJob with a 5-second delay and will be activated again — the event is not lost.
BPMN configuration
A Service Task is described with zeebe:taskDefinition, and routing properties with zeebe:taskHeaders:
<bpmn:serviceTask id="Activity_1h87cc2" name="Log Info">
<bpmn:extensionElements>
<zeebe:taskDefinition type="finance" />
<zeebe:taskHeaders>
<zeebe:header key="uni.connector" value="demo" />
<zeebe:header key="uni.action" value="log.info" />
<zeebe:header key="uni.version" value="v1" />
</zeebe:taskHeaders>
</bpmn:extensionElements>
</bpmn:serviceTask>
The value of zeebe:taskDefinition/@type is substituted into the Kafka topic pattern and must be listed in camunda-eight-client.jobs.types.
Job type list
A job type is a string chosen at modeling time. The same string is substituted into the Kafka topic name, so changing it requires updating connector configuration.
Each type is described by an object; max-jobs is set per type:
camunda-eight-client:
jobs:
enabled: true
types:
finance:
timeout-ms: 300000
max-jobs: 10
fetch-variables: []
| Parameter | Purpose | Default value |
|---|---|---|
timeout-ms | How long the job is locked to the worker | 60000 |
max-jobs | How many jobs to fetch per request | 50 |
fetch-variables | Variables to load; empty list means all | empty |
The list is required: POST /v2/jobs/activation accepts one type per request, so the worker makes as many requests per cycle as there are types.
Required properties
| Property | Purpose |
|---|---|
uni.connector | Logical integration group |
uni.action | Business action |
uni.version | Handler version |
The combination of these three parameters uniquely identifies the handler.
If uni.connector or uni.action is missing, the job is not dispatched: the worker returns it to the engine with zero retries, and Camunda creates an incident. uni.version is optional.
Handler resolution
The handler is resolved by the triad:
connector + action + version
Example:
demo / log.info / v1
If no handler is found, the following exception is thrown:
IllegalStateException:
No handler found for connector/action/version: demo/log.info/v1
This is fail-fast behavior.
Implementing a connector
To create an integration handler, implement the interface:
public interface ExternalTaskHandler {
String connector();
String action();
default String version() {
return null;
}
ExternalTaskResult handle(ExternalTaskJob job)
throws BpmnErrorException, ExternalTaskFailureException;
}
Example:
@Slf4j
@Component
public class DemoLogInfoHandler implements ExternalTaskHandler {
@Override
public String connector() {
return "demo";
}
@Override
public String action() {
return "log.info";
}
@Override
public String version() {
return "v1";
}
@Override
public ExternalTaskResult handle(ExternalTaskJob job) {
log.info("DEMO handler invoked. taskId={}, businessKey={}, activityId={}",
job.getTaskId(),
job.getBusinessKey(),
job.getActivityId()
);
return ExternalTaskResult.builder()
.taskId(job.getTaskId())
.status(ExternalTaskResult.Status.COMPLETED)
.variables(Map.of("demoHandled", true))
.build();
}
}
The handler must be a Spring Bean.
Kafka Topics
Jobs topic
Format:
{organization}.unibpm.external-task.jobs.{taskType}.{environment}
where {taskType} is the value of zeebe:taskDefinition/@type from the BPMN model.
Example:
reunico.unibpm.external-task.jobs.finance.prod
The pattern is set by camunda-eight-client.jobs.kafka-topics.jobs-pattern.
Results topic
Unlike the jobs topic, the results topic name is not built from a pattern; it is set in full by camunda-eight-client.jobs.kafka-topics.results:
reunico.unibpm.external-task.results
Next to it is the dead-letter topic — camunda-eight-client.jobs.kafka-topics.dlq, default reunico.unibpm.external-task.dlq.
Job publishing is implemented; the reverse path is not: no component is subscribed to the results topic, so the external-task result is not yet returned to the engine. This section describes the message contract, not a working end-to-end scenario.
Job contract
| Field | Type | Source |
|---|---|---|
taskId | String | jobKey |
processInstanceId | String | processInstanceKey |
businessKey | String | value of the businessKey variable |
activityId | String | service-task elementId |
lockExpirationTime | Instant | activated job deadline |
retries | Integer | remaining retries |
connector, action, version | String | from zeebe:taskHeaders |
variables | Map | variables available to the job |
extensionProperties | Map | all zeebe:taskHeaders |
Identifiers are numeric Zeebe keys. Example message:
{
"taskId": "2251799813685569",
"processInstanceId": "2251799813685560",
"businessKey": "57a4781b-dd65-4154-bde6-5de26eafef21",
"activityId": "Activity_1h87cc2",
"lockExpirationTime": "2026-09-18T10:45:17Z",
"retries": 3,
"connector": "demo",
"action": "log.info",
"version": "v1",
"variables": {
"amount": 1000
},
"extensionProperties": {
"uni.connector": "demo",
"uni.action": "log.info",
"uni.version": "v1"
}
}
businessKey is the Cabinee ticket identifier. Handlers rely on it, not on taskId or processInstanceId: the latter are numeric engine keys and must not be parsed as UUIDs.
Result contract
Successful completion
{
"taskId": "...",
"status": "COMPLETED",
"variables": {
"result": "OK"
}
}
BPMN Error
{
"taskId": "...",
"status": "BPMN_ERROR",
"errorCode": "BUSINESS_ERROR",
"errorMessage": "Validation failed"
}
Failure (technical error)
{
"taskId": "...",
"status": "FAILED",
"failureMessage": "System error",
"retries": 3,
"retryDuration": 60000
}
Extracting task properties
Properties arrive with the activated job in customHeaders and are placed in the message as extensionProperties. No separate engine request is required.
The handler reads them like this:
@Override
public ExternalTaskResult handle(ExternalTaskJob job) {
String action = job.getExtensionProperties().get("uni.action");
...
}
If the element has no zeebe:taskHeaders block, the field arrives empty.
Spring Boot Starter
The integration client is connected through the starter:
unibpm-external-task-client-starter
It automatically:
- Registers a Kafka consumer
- Creates a handler registry
- Connects the dispatcher
- Configures JSON serialization
- Supports conditional autoconfiguration
Error handling
| Scenario | Behavior |
|---|---|
| No handler | IllegalStateException |
| Handler threw RuntimeException | FAILED is sent |
| Handler threw BpmnErrorException | BPMN_ERROR is sent |
| Message cannot be deserialized | Consumer error (ErrorHandlingDeserializer is recommended) |
| Redelivery | The handler must be idempotent |
Design principles
- BPMN defines the integration contract
- Kafka provides asynchronous decoupling
- Handlers are stateless
- Versioning provides backward compatibility
- The engine does not depend on specific integrations
- Integrations scale independently
Summary
Cabinee External Task Integration provides:
- A clear contract
- Scalability
- Extensibility
- Isolation of business context
- A production-ready architecture