Note
π§ Work in progress. This is a solution template β a reference to fork and build on, for our consultants and anyone else β not a product that ships. It's still being fleshed out, so parts may be incomplete and it may not yet fully demonstrate what it's meant to. Treat it as a living example, and expect it to keep evolving.
A ready-to-fork starting point for automating a business process on CIB seven (the community fork of Camunda 7) with an embedded engine, Spring Boot and Kotlin β one complete, runnable, production-shaped BPMN service you can clone and make your own.
This variant talks to the engine through the process-engine-api
(bpm-crafters) abstraction instead of the plain CIB seven APIs: the BPMN service tasks are external
tasks consumed by @ProcessEngineWorker beans, and starting instances / correlating messages /
completing user tasks all go through the process-engine-api. The engine is still embedded, so
Cockpit/Tasklist and the /engine-rest API remain available.
Meet MiraVelo β a (fictional) lifestyle bike brand for the quarter-life-crisis crowd: gravel bikes for the weekends that count, road bikes for everyone who just wants to feel the asphalt. MiraVelo sells its bikes on a leasing model for private and corporate customers, and this project automates that leasing application from the first request to an active lease.
It's a made-up company, so nobody gets hurt when the DMN politely declines a 15-year-old's application for a carbon road bike.
Most engine examples stop at a happy-path service task. This one deliberately walks through the broad palette of BPMN elements you actually meet in real processes β and the engineering scaffolding around them β so a new project starts from something complete instead of a blank page:
- a message start event, service tasks (process-engine-api workers) and a DMN business-rule task;
- an embedded sub-process with an event-based gateway (sign vs. a 14-day deadline) and a non-interrupting 7-day reminder timer;
- a parallel fork/join, and a user task with a Camunda Form β completable in the Tasklist or via a REST endpoint;
- compensation / SAGA handlers guarded by error and escalation boundary events;
- a call activity into a second process, a message event sub-process (application withdrawal), and a terminate end event.
service/
common-architecture-tests/ reusable ArchUnit + Konsist rule suite (src/main)
app/ the CIB seven bike-leasing service (hexagonal)
adapter/inbound/rest domain REST controllers
adapter/inbound/cibseven process-engine-api workers for the BPMN service tasks
adapter/outbound/cibseven drives the engine through the process-engine-api
adapter/outbound/db JPA persistence (leasing applications + bike portfolio)
adapter/outbound/dealer simulated bike dealer (stock check + order)
adapter/process generated *ProcessApi (bpmn-to-code) + engine config
application/{port,service} use-case ports and their services
domain/{leasing,bike} pure domain model
resources/{bpmn,dmn,forms} the process models and Camunda Forms
resources/db/migration Flyway forward-only migrations
openapi/openapi.json generated by a test, committed, drift-gated in CI
bruno/ REST scenarios (happy-path / escalation / abort / not-solvent / β¦)
package.json BPMN linting (bpmnlint) β tooling at the repo root
stack/ Postgres dev stack (docker compose)
docs/{README.md, adr/, assets/} ADR index + records + the process diagram
.github/ pre-merge pipeline + Dependabot
- Stack: Kotlin 2.4 Β· Spring Boot 4 Β· CIB seven 2.2 (embedded) Β· process-engine-api (bpm-crafters) Β· PostgreSQL Β· Gradle with a
libs.versions.tomlversion catalog. - Generated process API: the
bpmn-to-codeGradle plugin turns each.bpmninto a typed*ProcessApiobject, so element ids, messages, timers and variables are compile-checked constants used by both delegates and tests. - Forms: Camunda Forms (
.form) are deployed with the process and render in the CIB seven Tasklist/Cockpit for the user tasks. - BPMN linting:
bpmnlint(bpmnlint:recommendedplus the camunda-platform-7 and@miragon/rulesplugins) gates the.bpmnmodels. The tooling lives at the repo root βnpm ci && npm run lint:bpmnβ and runs in CI before the Gradle build. - API contract: springdoc generates
openapi/openapi.jsonfrom the controllers; it is committed and drift-gated in CI, so any REST change that isn't regenerated fails the build. - Database: schema is owned by Flyway forward-only migrations
(
resources/db/migration); Hibernate onlyvalidates. - Observability: Spring Boot Actuator exposes
health(withliveness/readinessprobes) and Prometheus metrics under/actuator.
- Hexagonal architecture keeps the engine and framework at the edges: the domain and use cases
never depend on CIB seven, so business logic is testable and the engine is replaceable. The
:service:common-architecture-testsmodule enforces this with ArchUnit (bytecode: layering, dependency direction, naming) and Konsist (source: one declaration per file, no wildcard imports) β one line wires it into a service:class ArchitectureTest : ServiceArchitectureTest(...). - Unit tests (JUnit 5 + MockK) cover every domain type, application service and adapter with
given/when/then comments and shared
testLeasingApplication(...)builders β controllers via@WebMvcTest, persistence via@DataJpaTest. The workers are covered by the process tests. - Mutation testing (
pitest) gates PRs at a score of 80: coverage says a line ran, mutation says a test would have noticed. It runs diff-scoped on PRs and a full-module sweep nightly. - Process tests (
cibseven-bpm-assert) drive the deployed model β timers and async continuations are fired and messages correlated by hand, while the real@ProcessEngineWorkerbeans consume the external service tasks β covering happy-path, escalation, abort, DMN rejection, and the bike-unavailable β alternative-selection loop. - Model validation (
bpmn-to-code-testing) checks the.bpmnmodels structurally at build time (BpmnRules.all()plus a custom rule requiring every service task to be an external task with a topic). - Bruno + CI proves the same scenarios against the running app: domain REST endpoints drive the
business actions, and the CIB seven
/engine-restAPI completes user tasks and fires timer jobs so the whole flow runs in the pipeline without real 14-day waits. - Dependabot keeps Gradle, the Postgres image and GitHub Actions current.
Every non-obvious decision is recorded as an Architecture Decision Record β start at the docs index and its ADRs (0001β0011) to read the why before changing the what.
# 1. start Postgres
docker compose -f stack/docker-compose.yml up -d
# 2. run the app (CIB seven Cockpit/Tasklist at http://localhost:8080/camunda, admin/admin)
./gradlew :service:app:bootRun
# 3. lint the BPMN models (tooling lives at the repo root)
npm ci && npm run lint:bpmn
# 4. drive the scenarios (build + arch + process tests first, then the REST flows)
./gradlew build
cd bruno && npx --yes @usebruno/cli@4.0.0 run . --env local -rPrefer containers? ./gradlew :service:app:bootBuildImage builds an OCI image
(cibseven-process-engine-api-example/app:β¦) with Spring's buildpacks β no Dockerfile β to run
against the Postgres dev stack. See CONTRIBUTING.md for the details.
Start a case with POST http://localhost:8080/api/bike-leasing
({ "customerName": β¦, "email": β¦, "age": 35, "monthlyNetIncome": 3500, "bikeId": "BIKE-900", "bikeModel": "Gravel Explorer 900" }).
The age and monthlyNetIncome feed the checkCreditRating DMN; the bikeId identifies the bike and
is the only bike attribute the engine ever carries. The descriptive bikeModel lives in a separate
bike portfolio aggregate (its own bike_portfolio table, keyed by bikeId) β never as a process
variable β and GET /api/bike-leasing/{id} resolves it back from there. Alongside it a small set of
read endpoints round out the API: GET /api/bike-leasing lists applications (paged), GET /api/bikes
returns the bike portfolio, and GET /api/tasks/clarify-alternative lists the open clarification
tasks. Every endpoint is described by the committed, drift-gated openapi/openapi.json.
If the requested bike is out of stock, the Clarify alternative with customer user task can be resolved
two ways, a deliberate contrast:
- the recommended path β a client calls
POST β¦/api/bike-leasing/{id}/clarify-alternative, which routes through the domain (persisting the chosen alternative) before completing the task; versus - the form-only path on
clarify-returnincancel-bike-order.bpmn, kept as a counter-example: completing it via the Camunda Form or/engine-restnever touches the domain, so its data lands only in process variables (see thebpmn:documentationon each task).
Bike availability itself is decided by a BikeDealerPort outbound adapter (checkAvailability /
order) whose small out-of-stock deny-list drives the branch.
Want to teach transaction boundaries, retries and incidents? Submit a request for the poison bike
BIKE-FAIL: the simulated dealer "outage" fails the Order bike from dealer external task, its
retries count down to zero (3 attempts, per the adapter's retry policy), and an incident appears
in the Cockpit to analyze and retry. A ready-to-run Bruno collection lives in bruno/06-incident-demo/.
Contributions are welcome. Please open an issue to discuss substantial changes first, keep the
architecture tests green (./gradlew build), and use
Conventional Commits for commit messages and PR titles.
Licensed under the MIT License.
