Skip to content

Spring Boot integration

Stephan Pelikan edited this page Aug 26, 2026 · 82 revisions

Using VanillaBP in Spring Boot Projects

This section explains the minimum you need to know to get VanillaBP running in a Spring Boot project: structure and dependencies, no theory.

  1. 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.
  2. 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.
  3. 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 via vanillabp.outbox.* properties.
  4. Configure the adapters
    Each BPMS used gets a section below vanillabp.adapters. Optional settings cover the behavior on deployment failures and resilience against temporarily unavailable BPMS.

Choose an appropriate project structure

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.

Add the required dependencies to the correct Maven/Gradle modules

Depending on your project structure, you need to add the following dependencies.

  1. 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.

  2. 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.

Aligning versions: the VanillaBP BOM

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.

Configure the transaction outbox

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 a MongoTransactionManager bean 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-outbox

A 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.

What the outbox guarantees

  • Every scheduled operation carries an idempotency key the store enforces as unique AMONG THE ENTRIES STILL WAITING for their dispatch, so scheduling the same operation again before the first one reached the BPMS is a no-op. The key says the operation is planned once, not that it ever happened: the same operation after the dispatch is a new one and is carried out. Starting a workflow is keyed by workflow module, BPMN process and aggregate ID, so there is one workflow per aggregate as long as the start is pending; correlating a message is keyed only if you pass a correlation ID (without one, the same message may legitimately be correlated again). The key names its operation, so completing and cancelling one task no longer share it. A workflow start by message is the one deliberate exception: it shares the plain start's key.
  • A discarded schedule is logged as a warning, naming what was not planned and both causes VanillaBP cannot tell apart: a repeated dispatch of something which happened already, or a second legitimate operation lost against one still waiting. See Message correlation for what a loop or a multi-instance activity has to do about it.
  • Dispatched entries are marked DONE, not deleted. They are removed asynchronously after vanillabp.outbox.retention (7 days by default), which keeps them readable for support; it does not extend the deduplication window, which ends with the dispatch. Shorten it if the store grows too much; do not set it to zero. On the inbound side the same property is a correctness setting (see below), and that is the one reason not to make it short.
  • 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 @WorkflowTask methods should be idempotent anyway (see Workflow tasks).
  • Entries that keep failing are blocked after block-after-attempts attempts 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.

What VanillaBP remembers about delivered tasks

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-deliveries keyed by the delivery, so uniqueness comes from the document ID. Records ride the Spring-managed MongoDB transaction, which needs a replica set and a MongoTransactionManager. 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-schema decides whether VanillaBP creates table respectively index itself, vanillabp.outbox.retention how long a record is kept after the BPMS last asked about it. That is the same property the outbox uses, and here it decides correctness rather than how long support can read along: a delivery arriving later than the retention finds no record and runs your handler a second time, while on the outbox side the deduplication window ends with the dispatch and the retention only keeps a dispatched entry readable, and vanillabp.outbox.jdbc.enabled / .mongo.enabled switch the defaults off. A task your application leaves open is asked about until somebody completes it, so its record stays while the retention deletes the records of the tasks which are done.
  • Any other persistence is served by a bean of your own implementing io.vanillabp.integration.spi.TaskDeliveryLog, assigned to aggregates via io.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. Such a store decides for itself how long a record lives; where the record of an open task has to survive the same way, implement stillOpen, which VanillaBP calls with the key of every redelivery it answers.
  • vanillabp.delivery.release-on-workflow-end (default false, overridable per workflow module as vanillabp.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.

Creating the tables with Liquibase or Flyway

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: false

With 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.

Two settings belong to every instance which is not the first one, and neither is optional:

Flyway
    .configure()
    .dataSource(dataSource)
    .locations("classpath:vanillabp/schema/flyway/postgresql")
    .table("flyway_schema_history_vanillabp")
    .baselineOnMigrate(true)   // the schema already holds the tables of the other owners
    .baselineVersion("0")      // the default baseline is 1, which would skip V1.0.0 silently
    .load();

baselineOnMigrate is what lets an instance work in a schema that is not empty, and next to another history that is always the case: without it Flyway finds tables it has no history for and refuses with Found non-empty schema(s) "PUBLIC" but no schema history table. baselineVersion has to follow, because the default baseline is version 1, so every migration numbered 1.0.0 counts as applied, is skipped, and Flyway reports success while the table it describes never appears.

What makes the schema exist before anything reads it is a FlywayMigrationInitializer per instance. Spring Boot's Flyway module lets the entity manager factory depend on beans of that type, not on Flyway beans, so an instance without an initializer migrates whenever its bean happens to be created, which may be after Hibernate looked.

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.

Both shapes are built and tested in the blueprints persistence-liquibase and persistence-flyway, including the three owners of schema a workflow application usually has: VanillaBP, the workflow module and the application itself.

The one table which is not VanillaBP's

On Spring Boot the phase-two outbox is gruelbox, and TXNO_OUTBOX is gruelbox's table, created by its own migrator and therefore not part of vanillabp-schema. create-schema: false switches that migrator off together with VanillaBP's table creation, and a custom vanillabp.outbox.jdbc.table switches it off as well, since the migrator only ever targets the default name. Both cases leave the table to the application, and both are checked at startup like VanillaBP's own two: a missing table ends the boot with a message naming it and the property which would have created it.

Nothing of that has to be copied out of gruelbox's source. Gruelbox writes its statements itself:

final var writer = new StringWriter();
DefaultPersistor
    .builder()
    .dialect(Dialect.POSTGRESQL_9)   // the dialect matching your database
    .build()
    .writeSchema(writer);

What comes back is the history of gruelbox's migrations for that database, from the CREATE TABLE to the last ALTER, and applying it in that order on an empty database yields the table gruelbox expects. TXNO_SEQUENCE is part of it and is read as soon as an entry is ordered within a topic. TXNO_VERSION does not appear, since it is the bookkeeping of the migrator you just switched off. A test which regenerates the statements and compares them to your migration turns a gruelbox upgrade into a failed build instead of a failed deployment.

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.

Configure the adapters

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.

Zero configuration: the classpath IS the configuration

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-module descriptor) 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 remain

The conventions at work:

  • The adapter's type defaults to its ID. vanillabp.adapters.<id>.type is 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 their type. This default exists for applications upgrading from VanillaBP 1, where there was one adapter and its ID was its type. It is not a recommendation: an ID which says WHICH system it is (camunda8-saas next to camunda8-onprem, camunda7-loans next to camunda7-claims) is what an operator reads in a log line, in a metric tag and in a process definition id, and a migration from one Camunda 8 configuration to another needs two names anyway, because both cannot be called camunda8. An adapter ID must never be renamed while workflows are running: VanillaBP persists it to know what belongs to which BPMS. See naming adapter ids, and never renaming them.
  • A single configured adapter is the prioritized adapter. vanillabp.prioritized-adapters may be omitted as long as exactly one adapter is configured.
  • vanillabp.retired-adapters names the adapter ids this application USED to have. VanillaBP asks its stores at startup which ids they still hold waiting outbox entries or records of open tasks for, and names one which is not configured any more: it was renamed, which loses workflows, or removed too early. Naming it here says the leftovers are known, see naming adapter ids, and never renaming them.

Several BPMS: naming the order is the whole configuration

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 here

The 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: warn

Adapter-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.

Migrating a single workflow (workflow-level overrides)

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
            - camunda7

Two 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 workflows which 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-level prioritized-adapters entry referencing an unknown adapter ID aborts booting, like on the other levels.

Overriding configuration via environment variables

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.

Clone this wiki locally