Camunda BPMN
Experimental module
The experimental module is fully working and tested, but requires additional usage validation and analysis.
Therefore, the API may receive minor changes before full readiness.
Camunda 7 is deprecated
CamundaEngineBpmnModule is marked @Deprecated because Camunda 7 has reached end of life.
The module still works and is still shipped, but no new capabilities are planned for it.
For new services consider Camunda 8 or the Operaton engine, a community fork of Camunda 7.
The module connects an embedded Camunda 7 engine for executing BPMN processes inside a Kora application.
It creates and configures ProcessEngine, connects it to a JDBC data source, registers delegates from the application graph, deploys BPMN / FORM / DMN resources from classpath, and adds execution telemetry.
To expose the Camunda 7 REST API over HTTP, use the separate Camunda 7 REST module alongside this one.
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
The module requires the JDBC module.
By default, the main application DataSource is used, but you can provide a separate DataSource with the @Tag(CamundaBpmn.class) tag when needed.
Configuration¶
Example of the complete configuration described by the CamundaEngineBpmnConfig interface:
camunda {
engine {
bpmn {
jobExecutor {
corePoolSize = 5 //(1)!
maxPoolSize = 25 //(2)!
queueSize = 25 //(3)!
maxJobsPerAcquisition = 2 //(4)!
virtualThreadsEnabled = false //(5)!
}
deployment {
tenantId = "Camunda" //(6)!
name = "KoraEngineAutoDeployment" //(7)!
deployChangedOnly = true //(8)!
resources = ["classpath:bpm"] //(9)!
delay = "1m" //(10)!
}
parallelInitialization {
enabled = true //(11)!
validateIncompleteStatements = true //(12)!
}
admin {
id = "admin" //(13)!
password = "admin" //(14)!
firstname = "Ivan" //(15)!
lastname = "Ivanov" //(16)!
email = "admin@mail.ru" //(17)!
}
telemetry {
logging {
enabled = false //(18)!
stacktrace = true //(19)!
}
metrics {
enabled = false //(20)!
engineMetrics = false //(21)!
slo = [1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000] //(22)!
tags = { //(23)!
"key1" = "value1"
"key2" = "value2"
}
}
tracing {
enabled = true //(24)!
attributes = { //(25)!
"key1" = "value1"
"key2" = "value2"
}
}
}
}
}
}
- Minimum number of permanently alive threads in
JobExecutor(default:5). - Maximum number of threads in
JobExecutor(default:25). JobExecutortask queue size before new tasks are rejected (default:25).- Maximum number of jobs acquired by
JobExecutorin one request (default:Runtime.getRuntime().availableProcessors() * 2). - Use virtual threads as the
JobExecutorbase (default:false). When this option is enabled, pool and queue size settings are not used. tenantidentifier for resource deployment (default not specified, optional).- Resource deployment name (default:
KoraEngineAutoDeployment). - Deploy only changed resources through
Camundaduplicate filtering (default:true). - List of paths for finding
BPMN/FORM/DMNresources (required, default not specified). Only paths with theclasspath:prefix are supported. - Delay before deploying resources to the engine (default not specified, optional).
- Enable parallel engine initialization (default:
true). - Validate incomplete engine statements during parallel initialization (default:
true). Camundaadministrator identifier (required, default not specified). The wholeadminsection is optional.Camundaadministrator password (required, default not specified). The wholeadminsection is optional.Camundaadministrator first name (default not specified, optional). If not specified, uppercaseidis used.Camundaadministrator last name (default not specified, optional). If not specified, uppercaseidis used.Camundaadministrator email address (default not specified, optional). If not specified,<id>@localhostis used.- Enables module logging (default:
false). - Enables error stack trace logging (default:
true). - Enables module metrics (default:
false). - Enables
Camunda's own engine and task metrics collected into its database tables (default:false). - SLO configuration for metrics (default:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO). - Metric tags (default:
{}). - Enables module tracing (default:
true). - Tracing attributes (default:
{}).
camunda:
engine:
bpmn:
jobExecutor:
corePoolSize: 5 #(1)!
maxPoolSize: 25 #(2)!
queueSize: 25 #(3)!
maxJobsPerAcquisition: 2 #(4)!
virtualThreadsEnabled: false #(5)!
deployment:
tenantId: "Camunda" #(6)!
name: "KoraEngineAutoDeployment" #(7)!
deployChangedOnly: true #(8)!
resources: #(9)!
- "classpath:bpm"
delay: "1m" #(10)!
parallelInitialization:
enabled: true #(11)!
validateIncompleteStatements: true #(12)!
admin:
id: "admin" #(13)!
password: "admin" #(14)!
firstname: "Ivan" #(15)!
lastname: "Ivanov" #(16)!
email: "admin@mail.ru" #(17)!
telemetry:
logging:
enabled: false #(18)!
stacktrace: true #(19)!
metrics:
enabled: false #(20)!
engineMetrics: false #(21)!
slo: [1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000] #(22)!
tags: #(23)!
key1: value1
key2: value2
tracing:
enabled: true #(24)!
attributes: #(25)!
key1: value1
key2: value2
- Minimum number of permanently alive threads in
JobExecutor(default:5). - Maximum number of threads in
JobExecutor(default:25). JobExecutortask queue size before new tasks are rejected (default:25).- Maximum number of jobs acquired by
JobExecutorin one request (default:Runtime.getRuntime().availableProcessors() * 2). - Use virtual threads as the
JobExecutorbase (default:false). When this option is enabled, pool and queue size settings are not used. tenantidentifier for resource deployment (default not specified, optional).- Resource deployment name (default:
KoraEngineAutoDeployment). - Deploy only changed resources through
Camundaduplicate filtering (default:true). - List of paths for finding
BPMN/FORM/DMNresources (required, default not specified). Only paths with theclasspath:prefix are supported. - Delay before deploying resources to the engine (default not specified, optional).
- Enable parallel engine initialization (default:
true). - Validate incomplete engine statements during parallel initialization (default:
true). Camundaadministrator identifier (required, default not specified). The wholeadminsection is optional.Camundaadministrator password (required, default not specified). The wholeadminsection is optional.Camundaadministrator first name (default not specified, optional). If not specified, uppercaseidis used.Camundaadministrator last name (default not specified, optional). If not specified, uppercaseidis used.Camundaadministrator email address (default not specified, optional). If not specified,<id>@localhostis used.- Enables module logging (default:
false). - Enables error stack trace logging (default:
true). - Enables module metrics (default:
false). - Enables
Camunda's own engine and task metrics collected into its database tables (default:false). - SLO configuration for metrics (default:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO). - Metric tags (default:
{}). - Enables module tracing (default:
true). - Tracing attributes (default:
{}).
The deployment section is optional: if it is not specified, the module does not automatically deploy resources.
If the section is specified, resources must contain at least one path.
Resources are searched recursively in classpath; unsupported paths without the classpath: prefix are skipped.
Module metrics are described in the Metrics Reference section.
Deployment¶
When the deployment section is present, the module automatically deploys process resources into the engine after it is created.
Resources are placed on the classpath (usually under src/main/resources) and referenced by the resources list:
- At least one path is required when the
deploymentsection is present. Only paths with theclasspath:prefix are supported.
Given the following layout, the classpath:bpm path is scanned recursively and every supported resource under it is deployed:
Deployment rules to keep in mind:
- Supported resource types are
BPMNprocess models,FORMforms, andDMNdecision tables.CMMNcase models are not supported by this integration — the engine is built withoutCMMN, and case queries always return empty results. - Only paths with the
classpath:prefix are deployed. Any other path is skipped with a warning in the log. - Paths are scanned recursively, so nested directories under the listed path are included.
- Resources are found both in exploded directories and inside
JARfiles, so aclasspath:bpmpath keeps working in a packaged distribution. - With
deployChangedOnly = true(default)Camundaduplicate filtering is enabled, so only resources that changed since the previous deployment are redeployed. - The optional
tenantIdbinds the deployment to a specifictenant, anddelaypostpones the deployment for the configured duration after startup. - The deployment is registered under the
name(defaultKoraEngineAutoDeployment). - If the whole
deploymentsection is omitted, the module does not deploy any resources — you are expected to deploy them yourself throughRepositoryService.
Delegates¶
Camunda can call application components as process delegates.
Regular JavaDelegate components are registered in the engine expression context by the full class name (canonicalName) and by the short class name (simpleName).
Inside execute(...) you read and write process variables through DelegateExecution:
@Component
public final class ScoreCustomerDelegate implements JavaDelegate {
private static final Logger logger = LoggerFactory.getLogger(ScoreCustomerDelegate.class);
@Override
public void execute(DelegateExecution execution) {
int scoring = ThreadLocalRandom.current().nextInt(1, 100);
logger.info("Scored {} with result {}.", execution.getBusinessKey(), scoring);
execution.setVariable("result", scoring);
}
}
@Component
class ScoreCustomerDelegate : JavaDelegate {
private val logger = LoggerFactory.getLogger(ScoreCustomerDelegate::class.java)
override fun execute(execution: DelegateExecution) {
val scoring = ThreadLocalRandom.current().nextInt(1, 100)
logger.info("Scored {} with result {}.", execution.businessKey, scoring)
execution.setVariable("result", scoring)
}
}
Because a JavaDelegate is registered by its short class name, a serviceTask in the BPMN model references it by simpleName through camunda:delegateExpression:
<bpmn:serviceTask id="Activity_0tusr5p" name="Score Customer"
camunda:delegateExpression="${ScoreCustomerDelegate}">
<bpmn:incoming>Flow_score_in</bpmn:incoming>
<bpmn:outgoing>Flow_score_out</bpmn:outgoing>
</bpmn:serviceTask>
Use KoraDelegate for an arbitrary delegate name.
The key() method returns canonicalName by default, but it can be overridden to specify the name used in BPMN expressions:
A delegate declared this way is referenced as ${myKey} in camunda:delegateExpression, so the name used in the process model no longer depends on the class name.
simpleName and canonicalName keep working for a KoraDelegate as well, so overriding key() only adds a name.
The same names are also available to BPMN script tasks and script expressions, because the module registers its delegate registry as a Camunda script Resolver.
In addition, a delegate is resolvable by camunda:class with its full class name: the module installs an ArtifactFactory that returns the graph component instead of instantiating a new object, so camunda:class="com.example.ScoreCustomerDelegate" calls exactly the component from the container with all its dependencies injected.
Every delegate is wrapped by KoraDelegateWrapperFactory before it is called: it opens a fresh logging MDC scope and module telemetry observation around execute(...).
You can provide your own KoraDelegateWrapperFactory as a @Component to change this behavior.
Engine Services¶
The module provides standard Camunda services as dependency graph components:
RuntimeServiceRepositoryServiceManagementServiceAuthorizationServiceDecisionServiceExternalTaskServiceFilterServiceFormServiceTaskServiceHistoryServiceIdentityService
These services can be injected into your components in the usual way, as can the ProcessEngine itself.
Starting and interacting with processes¶
Inject ProcessEngine (or any of the engine services above) into your components to start and drive process instances.
A process is started by its BPMN process id through RuntimeService, and process definitions can be queried through RepositoryService:
@Component
@HttpController("/camunda")
public final class CamundaController {
@Json
public record CamundaProcess(String instanceId, String businessKey) {}
private final ProcessEngine processEngine;
public CamundaController(ProcessEngine processEngine) {
this.processEngine = processEngine;
}
@Json
@HttpRoute(method = HttpMethod.GET, path = "/start/onboarding")
public HttpResponseEntity<CamundaProcess> startOnboarding() {
String businessKey = UUID.randomUUID().toString();
ProcessInstance instance = processEngine.getRuntimeService()
.startProcessInstanceByKey("Onboarding", businessKey);
return HttpResponseEntity.of(200, new CamundaProcess(instance.getId(), businessKey));
}
}
@Component
@HttpController("/camunda")
class CamundaController(private val processEngine: ProcessEngine) {
@Json
data class CamundaProcess(val instanceId: String, val businessKey: String)
@Json
@HttpRoute(method = HttpMethod.GET, path = "/start/onboarding")
fun startOnboarding(): HttpResponseEntity<CamundaProcess> {
val businessKey = UUID.randomUUID().toString()
val instance = processEngine.runtimeService
.startProcessInstanceByKey("Onboarding", businessKey)
return HttpResponseEntity.of(200, CamundaProcess(instance.id, businessKey))
}
}
A running process can be advanced from outside the engine as well: RuntimeService.correlateMessage(...) delivers a BPMN message event, and TaskService / FormService complete user tasks and submit forms:
@Component
@HttpController("/camunda/process/onboarding")
public final class OnboardingController {
private final FormService formService;
private final TaskService taskService;
private final RuntimeService runtimeService;
public OnboardingController(FormService formService, TaskService taskService, RuntimeService runtimeService) {
this.formService = formService;
this.taskService = taskService;
this.runtimeService = runtimeService;
}
@HttpRoute(path = "/cancel/{businessKey}", method = HttpMethod.GET)
public String customerCancellation(@Path String businessKey) {
runtimeService.correlateMessage("MessageCustomerCancellation", businessKey);
return "Cancelled: " + businessKey;
}
@HttpRoute(path = "/order/{businessKey}", method = HttpMethod.GET)
public String customerOrder(@Path String businessKey) {
Task task = taskService.createTaskQuery().processInstanceBusinessKey(businessKey).active().singleResult();
formService.submitTaskForm(task.getId(), Map.of("approved", true));
return "Approved: " + businessKey;
}
}
@Component
@HttpController("/camunda/process/onboarding")
class OnboardingController(
private val formService: FormService,
private val taskService: TaskService,
private val runtimeService: RuntimeService
) {
@HttpRoute(path = "/cancel/{businessKey}", method = HttpMethod.GET)
fun customerCancellation(@Path businessKey: String): String {
runtimeService.correlateMessage("MessageCustomerCancellation", businessKey)
return "Cancelled: $businessKey"
}
@HttpRoute(path = "/order/{businessKey}", method = HttpMethod.GET)
fun customerOrder(@Path businessKey: String): String {
val task = taskService.createTaskQuery().processInstanceBusinessKey(businessKey).active().singleResult()
formService.submitTaskForm(task.id, mapOf("approved" to true))
return "Approved: $businessKey"
}
}
DataSource and transactions¶
The engine persists its state through a JDBC DataSource, so the JDBC module is required.
By default the module reuses the main application DataSource, exposed to the engine under the @Tag(CamundaBpmn.class) tag.
To give the engine a dedicated data source, provide your own DataSource with that tag:
The CamundaEngineDataSource component abstracts the engine's DataSource together with its CamundaTransactionManager.
The default implementation runs JDBC over the @Tag(CamundaBpmn.class) DataSource; you can override CamundaEngineDataSource as a @Component to fully control how the engine obtains connections and manages transactions.
Because the engine runs with externally managed transactions, every engine command executes inside a transaction opened by CamundaTransactionManager.
A delegate that performs its own JDBC work can join that transaction instead of opening a second one.
inContinueTx(...) reuses the connection of the current engine transaction (opening a new one only if none is active), while inNewTx(...) always opens a new transaction; currentConnection() returns a handle to commit() / rollback() the current transaction:
@Component
public final class AuditDelegate implements JavaDelegate {
private final CamundaTransactionManager transactionManager;
public AuditDelegate(CamundaTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public void execute(DelegateExecution execution) {
transactionManager.inContinueTx(() -> {
// JDBC work sharing the engine transaction
});
}
}
A failing JDBC operation is reported as UncheckedSqlException.
Job executor and readiness¶
The engine runs asynchronous continuations and timers through a JobExecutor.
The implementation is selected by the jobExecutor.virtualThreadsEnabled option: when false (default) a thread-pool executor is used with threads named camunda-worker-N and sized by corePoolSize / maxPoolSize / queueSize / maxJobsPerAcquisition; when true a virtual-thread executor is used with threads named camunda-job-executor-N and the pool/queue sizes are ignored (see the Configuration callouts).
The module automatically registers a readiness probe that reports the application as UP only once the JobExecutor is active.
Until the job executor is activated the probe fails with Camunda BPMN Engine JobExecutor is not active, which keeps the application out of rotation while the engine is still starting.
With parallelInitialization.enabled = true (default) the engine starts in two stages: the first stage builds the engine with a reduced set of MyBatis statements so the application starts faster, and the second stage adds the remaining statements and activates the JobExecutor in parallel with the other configurators.
Setting parallelInitialization.enabled = false builds the engine in a single stage instead.
Admin user¶
When the admin section is present, the module provisions a Camunda administrator user, ensures the camunda-admin group with full authorizations exists, and adds the user to it (see the Configuration admin callouts).
This account is stored in the engine's identity service, so it authenticates against the Camunda 7 REST API and against any external Camunda web application working with the same database.
If the admin section is omitted, no user is created; if a user with the configured id already exists, nothing is changed.
Engine Configuration¶
For additional configuration, register a ProcessEngineConfigurator component.
The prepare(...) method is called before ProcessEngine is created and receives ProcessEngineConfiguration; setup(...) is called after the engine is created:
All setup(...) calls run in parallel on virtual threads, so a configurator must not rely on the order in which other configurators run.
The module itself contributes the admin-user configurator, the resource-deployment configurator, and the second-stage initialization configurator through the same mechanism.
The whole ProcessEngineConfiguration can be replaced as well: it is provided as a @DefaultComponent, so registering your own ProcessEngineConfiguration component takes precedence over the module's KoraProcessEngineConfiguration.
Plugins¶
You can register arbitrary ProcessEnginePlugin by providing them as components in the Kora dependency container.
The module collects all such components and passes them to the engine configuration when creating ProcessEngine:
@Component
public final class SimpleProcessEnginePlugin implements ProcessEnginePlugin {
@Override
public void preInit(ProcessEngineConfigurationImpl configuration) {
}
@Override
public void postInit(ProcessEngineConfigurationImpl configuration) {
}
@Override
public void postProcessEngineBuild(ProcessEngine engine) {
}
}
Camunda version¶
The detected Camunda version is available as an injectable CamundaVersion component.
Its version() returns the version string reported by the Camunda package, and isEnterprise() returns true when an enterprise (-ee) distribution is on the classpath:
Testing¶
For component tests the engine can be replaced with an in-memory one so that no external database is needed.
The ProcessEngineConfiguration component is replaced by a StandaloneInMemProcessEngineConfiguration that keeps the Kora-provided expression manager, artifact factory, id generator, job executor, and script resolver, so delegates from the graph are still resolved as usual:
public class InMemoryProcessEngineConfiguration extends StandaloneInMemProcessEngineConfiguration {
public InMemoryProcessEngineConfiguration(KoraAppGraph graph) {
setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_CREATE_DROP);
setJdbcUrl("jdbc:h2:mem:camunda;DB_CLOSE_ON_EXIT=FALSE");
setAuthorizationEnabled(false);
setJobExecutorActivate(true);
setExpressionManager(graph.getFirst(JuelExpressionManager.class));
setArtifactFactory(graph.getFirst(ArtifactFactory.class));
setIdGenerator(graph.getFirst(IdGenerator.class));
setJobExecutor(graph.getFirst(JobExecutor.class));
if (getResolverFactories() == null) {
setResolverFactories(new ArrayList<>());
}
getResolverFactories().add(graph.getFirst(KoraResolverFactory.class));
}
}
@KoraAppTest(Application.class)
class ProcessTests implements KoraAppTestGraphModifier, KoraAppTestConfigModifier {
@Mock
@TestComponent
private CamundaEngineDataSource mockDataSource;
@TestComponent
private ProcessEngine processEngine;
@Override
public KoraConfigModification config() {
return KoraConfigModification.ofString("""
camunda.engine.bpmn {
deployment.resources = "classpath:bpm"
}
""");
}
@Override
public KoraGraphModification graph() {
return KoraGraphModification.create()
.replaceComponent(ProcessEngineConfiguration.class, InMemoryProcessEngineConfiguration::new);
}
@Test
void processStarted() {
var instance = processEngine.getRuntimeService()
.startProcessInstanceByKey("Onboarding", UUID.randomUUID().toString());
assertNotNull(instance.getId());
}
}
class InMemoryProcessEngineConfiguration(graph: KoraAppGraph) : StandaloneInMemProcessEngineConfiguration() {
init {
databaseSchemaUpdate = ProcessEngineConfiguration.DB_SCHEMA_UPDATE_CREATE_DROP
jdbcUrl = "jdbc:h2:mem:camunda;DB_CLOSE_ON_EXIT=FALSE"
authorizationEnabled = false
isJobExecutorActivate = true
expressionManager = graph.getFirst(JuelExpressionManager::class.java)
artifactFactory = graph.getFirst(ArtifactFactory::class.java)
idGenerator = graph.getFirst(IdGenerator::class.java)
jobExecutor = graph.getFirst(JobExecutor::class.java)
if (resolverFactories == null) {
resolverFactories = ArrayList()
}
resolverFactories.add(graph.getFirst(KoraResolverFactory::class.java))
}
}
@KoraAppTest(Application::class)
class ProcessTests : KoraAppTestGraphModifier, KoraAppTestConfigModifier {
@Mock
@TestComponent
lateinit var mockDataSource: CamundaEngineDataSource
@TestComponent
lateinit var processEngine: ProcessEngine
override fun config(): KoraConfigModification = KoraConfigModification.ofString(
"""
camunda.engine.bpmn {
deployment.resources = "classpath:bpm"
}
""".trimIndent()
)
override fun graph(): KoraGraphModification = KoraGraphModification.create()
.replaceComponent(ProcessEngineConfiguration::class.java, ::InMemoryProcessEngineConfiguration)
@Test
fun processStarted() {
val instance = processEngine.runtimeService
.startProcessInstanceByKey("Onboarding", UUID.randomUUID().toString())
assertNotNull(instance.id)
}
}
Mocking CamundaEngineDataSource keeps the real application DataSource out of the test graph, and any delegate can be mocked with @Mock @TestComponent to assert that a process reached it.
Telemetry¶
The module reports its own logging, metrics, and tracing for delegate executions through the telemetry configuration section.
Logging is written to a logger named after the delegate class and produces a Camunda BPMN Engine started event before the call and a Camunda BPMN Engine finished delegate execution (or ... failed delegate execution) event after it, with processBusinessKey, processInstanceId, activityId, activityName, eventName, businessKey, and the processing time attached as structured fields.
Tracing creates a Camunda Delegate <canonicalName> span per delegate call with the eventName, processBusinessKey, and processInstanceId attributes.
Metrics are described in the Metrics Reference section, and the MDC scope opened by KoraDelegateWrapperFactory keeps this telemetry scoped to each delegate call.
To change what is reported, register your own DefaultCamundaEngineLoggerFactory or DefaultCamundaEngineMetricsFactory subclass as a @Component; the whole CamundaEngineTelemetryFactory is provided via @DefaultComponent and can be replaced as well.
Independently of the module telemetry, telemetry.metrics.engineMetrics toggles Camunda's own engine and task metrics, which the engine accumulates in its own database tables (disabled by default).