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 fork of Camunda 7) with an embedded engine, Spring Boot and Java β 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 Operaton 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 rule suite (src/main)
app/ the Operaton bike-leasing service (hexagonal)
adapter/inbound/rest domain REST controllers
adapter/inbound/operaton process-engine-api workers for the BPMN service tasks
adapter/outbound/operaton 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
checkstyle.xml source-style rules (one top-level class per file, no star imports)
stack/ Postgres dev stack (docker compose)
docs/{README.md, adr/, assets/} ADR index + records + the process diagram
.github/ pre-merge pipeline + Dependabot
- Stack: Java 21 Β· Spring Boot 4 Β· Operaton 2.1 (embedded) Β· process-engine-api (bpm-crafters) Β· PostgreSQL Β· Maven
multi-module (
./mvnw), versions centralized in the rootpom.xml. - Generated process API: the
bpmn-to-codeMaven plugin turns each.bpmninto a typed*ProcessApiclass, 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: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 Maven 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 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) β one line wires it into a service:class ArchitectureTest extends ServiceArchitectureTest. Source-level rules (one top-level class per file, no wildcard imports) are enforced by Checkstyle (checkstyle.xmlat the repo root) inside./mvnw verify. - Unit tests (JUnit 5 + Mockito in BDD style) 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 (
operaton-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, the bike-unavailable β alternative-selection loop, and a declined alternative. - 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 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 the Maven dependencies, the Postgres image and GitHub Actions current (Maven wrapper bumps are manual).
Every non-obvious decision is recorded as an Architecture Decision Record β start at the docs index and its ADRs (0001β0014) to 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)
# fresh clone? run `./mvnw -DskipTests install` once first
./mvnw -pl service/app spring-boot:run
# 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)
./mvnw verify
cd bruno && npx --yes @usebruno/cli@4.0.0 run . --env local -rPrefer containers? ./mvnw -DskipTests install && ./mvnw -pl service/app spring-boot:build-image -DskipTests builds an OCI image
(operaton-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 shows a
deliberate contrast:
- it reaches the domain either way β a client calls
POST β¦/api/bike-leasing/{id}/clarify-alternative, or a human completes the Camunda Form in the Tasklist. Both hand the chosenbikeIdto the process, and the re-order reads it and persists it on the application. The REST call additionally registers the bike's model in the portfolio; 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: an out-of-stock bike leaves the order
task through the bikeUnavailable error boundary event, so no order compensation is registered.
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 (./mvnw verify), and use
Conventional Commits for commit messages and PR titles.
Licensed under the MIT License.
