Repository navigation
Spring Boot integration
This section explains the minimum you need to know to get VanillaBP running in a Spring Boot project: structure and dependencies, no theory.
-
Choose an appropriate project structure
VanillaBP works for both monolithic applications and microservices. It scales from small, single-module projects to large, multi-module Maven or Gradle builds. The chosen setup determines which dependencies are required. -
Add the required dependencies to the correct Maven/Gradle modules
VanillaBP provides a small set of dependencies, each with a clearly defined purpose. Add each of them where it belongs. -
Configure the transaction outbox
Required only for adapters of remote BPMS: VanillaBP uses a transaction outbox to start workflows reliably. It works out of the box and can be tuned viavanillabp.outbox.*properties. -
Configure the adapters
Each BPMS used gets a section belowvanillabp.adapters. Optional settings cover the behavior on deployment failures and resilience against temporarily unavailable BPMS.
VanillaBP supports business process applications built as a monolith or as microservices. Both approaches work for single-module setups as well as for large, multi-module Maven or Gradle projects.
If you use multiple modules, you typically separate workflow modules from the main Spring Boot application. You find concrete examples on the workflow modules page.
To increase decoupling, each workflow module can provide its own configuration files (YAML and/or .properties) named after the workflow module ID. These files are automatically loaded and merged into the Spring Environment, with higher priority than application.yaml / application.properties. Profile-specific variants (e.g. loan-approval-prod.yaml) are supported as well. In multi-module projects, the configuration files can be placed in a subdirectory named after the workflow module ID (e.g. loan-approval/loan-approval.yaml), following the same convention used for all other workflow module resources.
The key principle: place everything a workflow module needs into its own configuration files. The application.yaml should only contain values that are truly cross-cutting — such as database settings, server configuration, or VanillaBP adapter definitions. Module-specific properties like endpoint URLs, timeouts, or feature flags belong in the module's own files. This way, the workflow module increasingly becomes a self-contained application within the application. See Configuration for details and precedence rules.
Depending on your project structure, you need to add the following dependencies.
-
VanillaBP adapter dependencies:
For each BPMS your application uses, add exactly one corresponding VanillaBP adapter to the main application module (in multi-module setups) or to the single module building the Spring Boot application. The adapter dependency transitively includes the VanillaBP Spring Boot integration, so no additional VanillaBP dependency is needed. See Available BPMS adapters for the list of adapters and their documentation. -
io.vanillabp:vanillabp-spring-boot-support:
This dependency contains the VanillaBP APIs required for compilation. Add it to pure workflow modules in multi-module setups. It keeps workflow modules independent of the concrete BPMS: BPMS-specific APIs are out of reach on purpose, so the hexagonal architecture cannot be violated by accident. The module's README lists the APIs it includes.
All VanillaBP artifacts an application uses are version-managed by io.vanillabp:vanillabp-bom. Import it once in the parent POM and omit the <version> of every VanillaBP dependency:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.vanillabp</groupId>
<artifactId>vanillabp-bom</artifactId>
<version>2.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>For Gradle use implementation platform("io.vanillabp:vanillabp-bom:2.0.0").
Two things are worth knowing about it:
- BPMS adapters are not part of the BOM. Each adapter is released independently of the platform integration, so its dependency carries its own version (see Available BPMS adapters).
- The BOM must never be older than your adapters. A version managed by your build always wins over the version an adapter needs transitively, and Maven applies this silently, even when it means a downgrade: the build stays green while the application would fail at runtime. Adapters therefore verify the platform version at startup and abort with a message naming the artifacts to raise. Raising the BOM to the version reported fixes it.
Adapters connecting to a remote BPMS (e.g. a SaaS offering) cannot take part in your application's local database transaction. To avoid inconsistencies (like a workflow started in the BPMS without the corresponding workflow aggregate being persisted), VanillaBP starts such workflows in two phases and schedules the second phase through a transaction outbox: an entry written in the same local transaction as the workflow aggregate and processed reliably after the commit, even if the application crashes and is restarted in between.
The embedded engine Camunda 7 uses the same path, although it could join your transaction: an operation which loses a concurrency conflict cannot be repeated inside the caller's transaction, and repeating only the engine part would advance the workflow while your data rolls back. An application on Camunda 7 therefore needs an outbox as well (see the adapter's wiki).
This works out of the box:
-
JPA: the outbox is based on gruelbox transaction-outbox and stores entries in the table
TXNO_OUTBOX(created automatically). -
MongoDB: entries are stored in the collection
vanillabp-phase-two-outbox. Note: to get transactional behavior, MongoDB has to run as a replica set and aMongoTransactionManagerbean has to be defined - Spring Boot auto-configures none. Without it the startup ends with a message naming it, since an application whose aggregates nothing covers cannot start a workflow at all.
The outbox is selected per workflow aggregate: the entry always rides the transaction persisting the aggregate. In a mixed-persistence application (e.g. a JPA workflow module and a MongoDB workflow module during a migration) both defaults are active at the same time and each aggregate is served by the outbox matching its persistence, detected from the aggregate's Spring Data repository. The availability of a required outbox is validated at startup: if the first-priority adapter of a process needs the two-phase start and no outbox can be resolved, the application fails to boot with a message naming the remedies.
The outbox can be tuned using these properties (defaults shown):
vanillabp:
outbox:
# fixed delay between two background polls for unprocessed entries
# (used for crash recovery and retries; after a commit the entry is
# dispatched immediately)
poll-interval: PT10S
# how long to wait after a failed attempt until the entry is retried
attempt-frequency: PT30S
# after how many failed attempts an entry is blocked (manual cleanup required)
block-after-attempts: 10
# how long successfully dispatched entries (marked DONE) are kept before
# they are deleted asynchronously - see "What the outbox guarantees"
retention: P7D
# whether to create the outbox table/collection automatically; disable
# if the database schema is managed manually (e.g. Flyway/Liquibase)
create-schema: true
jdbc:
# disable the JPA/gruelbox default (e.g. if you define your own outbox)
enabled: true
# custom table name; NOTE: the automatic gruelbox migration always targets
# TXNO_OUTBOX - a custom name requires the table to be created manually
#table: TXNO_OUTBOX
mongo:
# disable the MongoDB default (e.g. if you define your own outbox)
enabled: true
# custom collection name
collection: vanillabp-phase-two-outboxA custom store also inherits a promise: VanillaBP calls your aggregate persistence during phase two, on whatever thread your store dispatches on. The default store (gruelbox) runs each dispatch inside a transaction it manages, and Spring Data opens what it needs per call, so nothing is required of you here - but if your store dispatches from a thread pool of its own and your persistence needs a transaction spanning load and save, open one around the dispatch.
Applications using another persistence technology (or an existing outbox infrastructure) can define their own bean implementing io.vanillabp.integration.spi.PhaseTwoOutbox. Assigning an outbox to specific aggregates is required as soon as several outboxes exist, and it is the way to isolate a high-load process onto a dedicated store: define a bean implementing io.vanillabp.integration.spi.PhaseTwoOutboxAware<MyAggregate> returning the outbox to use (the implementation with the most specific aggregate class wins, like for aggregate persistence). Every outbox instance needs its own store (table/collection): two dispatchers polling the same store would compete and double-dispatch.
A custom store persists what the call carries and nothing more: the operation NAME (call.operation()), the workflow module, the BPMN process, the serialized aggregate ID, the optional adapter ID, the arguments and the idempotency key. It never interprets any of them - at dispatch time it hands the persisted values back to VanillaBP's router, which resolves the operation. Operation names may be namespaced (my-extension:NOTIFY) because extensions contribute operations of their own, so store the name as text of at least 255 characters.
- Every scheduled operation carries an idempotency key the store enforces as unique, so scheduling the same operation twice is a no-op. Starting a workflow is keyed by workflow module, BPMN process and aggregate ID, so there is one workflow per aggregate, no matter how often the start is scheduled. Correlating a message is keyed only if you pass a correlation ID (without one, the same message may legitimately be correlated again).
-
Dispatched entries are marked DONE, not deleted. They are removed asynchronously after
vanillabp.outbox.retention(7 days by default), which keeps the deduplication window open well beyond the dispatch itself. Shorten it if the store grows too much; do not set it to zero. -
Dispatch is at-least-once. A crash between the BPMS accepting an operation and the entry being marked DONE makes the entry be dispatched again after the restart. For workflow starts VanillaBP probes the recorded BPMS first and consumes the entry if the workflow is already known, which makes a duplicate start unlikely. The window is minimized, not closed, so never build on exactly-once semantics. Your
@WorkflowTaskmethods should be idempotent anyway (see Workflow tasks). -
Entries that keep failing are blocked after
block-after-attemptsattempts and then need manual attention. Monitoring the outbox store for blocked entries is an operations duty. -
A failure the BPMS answers the same way every time is blocked right away. An adapter may report a failure as permanent (
MigratableProcessService.isPhaseTwoFailureRepeatable), for instance a command Camunda 7 rejects as invalid. Repeating such an operation gains nothing, so the entry is blocked after the first attempt and logged as an error naming the operation. Everything else stays repeatable, which is what makes losing a concurrency conflict survivable.
The inbound counterpart of the outbox: a remote BPMS may hand the same task out again, and VanillaBP records what it processed so a repeated delivery is answered from that record instead of running the handler twice (see Workflow tasks). The store is picked per workflow aggregate, exactly like the outbox, so a record always rides the aggregate's own transaction:
- JPA/JDBC aggregates get a table
VANILLABP_TASK_DELIVERY, written through a connection bound to the Spring-managed transaction. This is not gruelbox, on purpose: gruelbox stores calls to be dispatched, a delivery record is a fact to be read back. - MongoDB aggregates get a collection
vanillabp-task-deliverieskeyed by the delivery, so uniqueness comes from the document ID. Records ride the Spring-managed MongoDB transaction, which needs a replica set and aMongoTransactionManager. Without one a record is written immediately and removed again when its transaction does not commit, so a redelivery of rolled-back work is not skipped - only a process dying in between leaves one behind. - The store settings are the outbox' ones:
vanillabp.outbox.create-schemadecides whether VanillaBP creates table respectively index itself,vanillabp.outbox.retentionhow long a record is kept, andvanillabp.outbox.jdbc.enabled/.mongo.enabledswitch the defaults off. - Any other persistence is served by a bean of your own implementing
io.vanillabp.integration.spi.TaskDeliveryLog, assigned to aggregates viaio.vanillabp.integration.spi.TaskDeliveryLogAware<MyAggregate>where several exist, exactly as the outbox does it. Without any store the startup says that deliveries are not deduplicated and names the way out. -
vanillabp.delivery.release-on-workflow-end(defaultfalse, overridable per workflow module asvanillabp.workflow-modules.<id>.delivery.release-on-workflow-end) lets a workflow which ended delete its own records instead of waiting for the retention, see Workflow tasks. Both stores above implement the deletion; a store of your own which does not is named at startup together with the property.
An application whose schema is a reviewed artifact applied by its deployment pipeline switches VanillaBP's table creation off and applies the statements itself:
vanillabp:
outbox:
create-schema: falseWith that switch off, VanillaBP checks at startup that the tables exist and ends the boot when one is missing, naming the table, the property and the artifact to apply - instead of failing at the first workflow start, hours after the deployment.
The statements come from the artifact io.vanillabp:vanillabp-schema, which contains nothing but
schema definitions, so a repository holding your migrations can depend on it without pulling the
runtime:
<dependency>
<groupId>io.vanillabp</groupId>
<artifactId>vanillabp-schema</artifactId>
</dependency>With Liquibase, include the changelog from the classpath in your own:
<include file="vanillabp/schema/changelog.xml" />A table name you configured is a changelog property, so a renamed table needs no fork of the
changelog: set vanillabp.outbox.table respectively vanillabp.delivery.table as a changelog
parameter.
With Flyway, apply the generated SQL - vanillabp/schema/flyway/<database>/V<version>__vanillabp_schema.sql
in the same artifact - with a second Flyway instance of its own: its own locations and its own
history table. That is not a detail: Flyway keeps one version timeline per history table, so
VanillaBP's versions would sooner or later collide with the numbers of your own migrations. With
flyway_schema_history_vanillabp as the history table, both timelines stay separate and each side
can be upgraded without touching the other.
Which databases: H2 and PostgreSQL are covered by tests. MySQL, MariaDB, SQL Server, Oracle and DB2 ship as well, generated by Liquibase from the same changelog, but nothing here runs them - the same list the Camunda 7 engine serves, so VanillaBP is never the narrower of the two.
On Spring Boot one table is not ours: the outbox is gruelbox,
whose TXNO_OUTBOX schema its own migrator creates. create-schema: false switches that migration
off as well, and a custom table name switches it off silently - the statements for it come from
gruelbox, not from VanillaBP. The Camunda 7 engine tables are the engine's business too: its JAR
ships a Liquibase changelog (org/camunda/bpm/engine/db/liquibase/camunda-changelog.xml) and plain
create scripts, and referencing those beats copying them, because the engine version decides what is
correct.
Each BPMS used by the application is configured as a named adapter below vanillabp.adapters. The key is the adapter ID, naming one instance of a BPMS; its type names the BPMS adapter serving it.
An application with one BPMS adapter dependency and one workflow module needs no vanillabp.* property at all. Everything VanillaBP could derive itself, it derives:
- the adapter: the single adapter type found in the classpath becomes the one configured adapter (its ID is the adapter type) and, being the only one, the prioritized adapter;
- the workflow module: every module found in the classpath (a
META-INF/workflow-moduledescriptor) is known without a section of its own; - the BPMN location: see the resources-location convention.
Only settings the BPMS itself needs (e.g. the address of a remote cluster) have to be written, and they live in the adapter's own section:
vanillabp:
adapters:
camunda8: # the ID names the adapter type ...
rest-address: http://localhost:8080 # ... only the BPMS' own settings remainThe conventions at work:
-
The adapter's type defaults to its ID.
vanillabp.adapters.<id>.typeis only required for a custom adapter ID (old-bpms,c7-legacy, …) which does not match an adapter type. Custom IDs are what makes several instances of the same BPMS possible, and they can never be derived, so they always need theirtype. -
A single configured adapter is the prioritized adapter.
vanillabp.prioritized-adaptersmay be omitted as long as exactly one adapter is configured.
With several adapters in the classpath VanillaBP never guesses which one starts new workflows, but as long as the adapter IDs are adapter types, naming the order is all it takes:
vanillabp:
prioritized-adapters:
- camunda8 # new workflows start here
- camunda7 # existing workflows complete hereThe adapter sections are derived from that list. Anything deviating from the convention has to be written down: a custom ID needs its type, and two IDs of the same type have to be distinguishable (what that means is decided by the adapter, e.g. separate databases or separate cluster endpoints).
The order in which adapters are used is defined by vanillabp.prioritized-adapters, overridable per workflow module and per single workflow, where the most specific non-empty list wins. New workflows always start in the first adapter, existing workflows are located by asking the adapters in priority order (cached after the first successful election). That is what enables BPMS migration scenarios without touching business code.
vanillabp:
prioritized-adapters:
- camunda8 # new workflows start here
- legacy # existing workflows complete here
adapters:
camunda8:
rest-address: http://localhost:8080 # ID = type, so 'type' can be omitted
legacy:
type: camunda7 # a custom ID needs its type
# optional: do not abort booting if deployment to this (non-first-priority)
# BPMS fails, e.g. because the old BPMS is temporarily unreachable during
# a migration; 'fail' (the default) aborts booting
deployment-failure: warnAdapter-specific settings (e.g. the connection to a remote BPMS) live in the SAME
section: each adapter contributes its own keys to vanillabp.adapters.<id>.* (see
your adapter's wiki for its keys). There is no separate
per-adapter namespace.
Adapter properties which vary by scope may additionally be configured per workflow
module (vanillabp.workflow-modules.<module>.adapters.<id>.<key>) and per workflow
(vanillabp.workflow-modules.<module>.workflows.<bpmnProcessId>.adapters.<id>.<key>).
The most specific configured value wins.
Note: deployment-failure: warn only takes effect for adapters which are not first in the priority list, neither for the workflow module nor for any of its workflows. A failing deployment of an adapter being first priority anywhere always aborts booting, since new workflows could not be started otherwise.
The prioritized adapters can be overridden for a single workflow (BPMN process) of a workflow module. This is the way to migrate one process to a new BPMS while the rest of the module stays:
vanillabp:
prioritized-adapters:
- camunda7
- camunda8
workflow-modules:
loan-approval:
prioritized-adapters:
- camunda7 # the module still runs on Camunda 7...
- camunda8
workflows:
RiskAssessment:
prioritized-adapters:
- camunda8 # ...but new 'RiskAssessment' workflows start in Camunda 8
- camunda7Two things happen automatically:
- Deployment union: the workflow module's resources are deployed to every adapter named at the module level or at any workflow level (each such adapter receives the module's full resources, because deployment is file-granular while the election is process-granular). An adapter named only for a single workflow therefore still gets the deployment it needs to start that workflow.
-
Validation: a workflow ID configured below
workflowswhich matches no executable BPMN process of the module does not prevent booting (the BPMN may arrive later during a migration) but is reported by a startup warning naming the known BPMN process IDs. A workflow-levelprioritized-adaptersentry referencing an unknown adapter ID aborts booting, like on the other levels.
All vanillabp.* properties can be overridden by environment variables (e.g. VANILLABP_ADAPTERS_CAMUNDA8_DEPLOYMENT_FAILURE=warn). One limitation applies on all platforms: an environment variable can only override an adapter or workflow module already declared in a configuration file. It cannot introduce a new entry whose ID contains dashes or dots, because the binder cannot reconstruct the ID from the variable's name (C8_CLOUD could mean c8-cloud, c8.cloud or a nested key). Instead of silently ignoring such a variable, VanillaBP fails the startup with a message naming the variable, the configured IDs and the fix: declare the ID in a configuration file (e.g. vanillabp.adapters.<id>.type) and use environment variables to override its values.
