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 Operaton (the community-driven 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.
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 (JavaDelegates) 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;
- an execution listener on a service task and a task listener on the user task β the two common listener hooks, wired as Spring beans just like the delegates;
- 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 Operaton bike-leasing service (hexagonal)
adapter/inbound/rest domain REST controllers
adapter/inbound/operaton JavaDelegates for the BPMN service tasks
adapter/outbound/operaton drives the engine (RuntimeService / TaskService)
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 migrations (Hibernate ddl-auto=validate)
openapi/openapi.json GENERATED by a test, COMMITTED, drift-gated in CI
bruno/ REST scenarios (happy-path / escalation / abort / not-solvent / β¦)
docs/{adr,assets} Architecture Decision Records + the process diagram
package.json + .bpmnlintrc root-level BPMN linting (bpmnlint) + git-hook installer
stack/ Postgres dev stack (docker compose)
.github/ pre-merge pipeline + Dependabot
- Stack: Kotlin 2.4 Β· Spring Boot 4 Β· Operaton 2.1 (embedded) Β· 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 Operaton Tasklist/Cockpit for the user tasks. - BPMN linting:
bpmnlint(bpmnlint:recommended) gates the.bpmnmodels via the root-levelnpm run lint:bpmn, run in CI before the Gradle build and on staged models by the pre-commit hook (npm run hooks:install). - REST API: domain endpoints (
POST /api/bike-leasingand its/sign-contract,/report-handover,/withdraw,/clarify-alternativeactions; the pagedGET /api/bike-leasinglist andGET /api/bike-leasing/{id};GET /api/bikes; and theGET /api/tasks/clarify-alternativeinbox) with RFC-7807 problem details. The contract is generated intoopenapi/openapi.json(committed, drift-gated) and served at/v3/api-docswith Swagger UI at/swagger-ui.html. - Operations: Spring Boot Actuator probes and Prometheus metrics at
/actuator/*; schema is owned by Flyway with Hibernateddl-auto=validate; mutation testing (pitest) gates at 80. - Container image:
./gradlew :service:app:bootBuildImageproduces themiravelo/appOCI image (Spring buildpacks, no Dockerfile).
- Hexagonal architecture keeps the engine and framework at the edges: the domain and use cases
never depend on Operaton, 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. JavaDelegates are covered by the process tests. - Process tests (
operaton-bpm-assert) drive the deployed model deterministically β timers and async continuations are fired and messages correlated by hand β 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 use a delegate expression). - Bruno + CI proves the same scenarios against the running app: domain REST endpoints drive the
business actions, and the Operaton
/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 under
docs/adr/ (0001β0011) β read the why before changing the what.
# 1. start Postgres
docker compose -f stack/docker-compose.yml up -d
# 2. run the app (Operaton Cockpit/Tasklist at http://localhost:8080/operaton, admin/admin)
./gradlew :service:app:bootRun
# 3. lint the BPMN models (root-level tooling)
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 -rStart 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.
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 job, its retries count
down (R3/PT10S), 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.
