Skip to content

Quarkus integration

Stephan Pelikan edited this page Oct 12, 2026 · 86 revisions

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

  1. Choose an appropriate project structure
    VanillaBP works for monolithic applications and for microservices. It scales from small projects with one module to large Maven or Gradle builds with many modules. Your setup decides which dependencies you need.
  2. Add the required dependencies to the correct Maven/Gradle modules
    VanillaBP has a small set of dependencies, each with a clear purpose. Add each of them where it belongs.
  3. Persisting workflow aggregates
    VanillaBP saves and loads your aggregate for you. Panache (JPA and MongoDB, repository and active record) and Spring Data repositories work without any setup. Any other persistence needs a bean of yours.
  4. Configure the transaction outbox
    Only needed for adapters of a remote BPMS (business process management system). VanillaBP uses a transaction outbox to start workflows reliably. It works without any setup, and you can tune it with the vanillabp.outbox.* properties.
  5. What VanillaBP remembers about delivered tasks
    The delivery log, which answers a repeated delivery instead of running your handler again, and the little it needs from you.
  6. Creating the tables with Liquibase or Flyway
    The four tables VanillaBP owns, written out for a project which manages its schema itself, and what the same switch means on MongoDB.
  7. Configure the adapters
    Each BPMS you use gets a section below vanillabp.adapters. Optional settings decide what happens when a deployment fails, and how to cope with a BPMS which is not available for a while.
  8. Building a native image
    What a native build changes, and the page with the details.

Choose an appropriate project structure

VanillaBP supports business process applications built as a monolith or as microservices. Both work with a single module as well as with large Maven or Gradle projects with many modules.

If you use several modules, you usually keep the workflow modules apart from the main Quarkus application. The workflow modules page has concrete examples.

To decouple them further, each workflow module can bring its own configuration files (YAML and/or .properties), named after the workflow module ID. VanillaBP loads these files automatically and merges them into the Quarkus configuration as defaults. Whatever the application configures wins over them, application.yaml and its profile files included. Profile-specific files (e.g. loan-approval-prod.yaml) work as well. In projects with several modules, you can put the configuration files into a subdirectory named after the workflow module ID (e.g. loan-approval/loan-approval.yaml). That is the same convention as for all other resources of a workflow module.

The main rule: put everything a workflow module needs into its own configuration files. The application.yaml should only hold values which really concern the whole application, such as database settings, server configuration or the definitions of the VanillaBP adapters. Properties of one module, like endpoint URLs, timeouts or feature flags, belong in the module's own files. So the workflow module more and more becomes a self-contained application inside the application. See Configuration for the details and for which file wins.

Add the required dependencies to the correct Maven/Gradle modules

Depending on your project structure, add these dependencies.

  1. io.vanillabp:vanillabp-quarkus-integration:
    This is the core VanillaBP Quarkus extension. Add it to the main application module (with several modules) or to the single module which builds the Quarkus application. At build time, it validates your code and wires your business code to the BPMS APIs.

  2. VanillaBP adapter dependencies:
    For each BPMS your application uses, add exactly one matching VanillaBP adapter. Adapters are separate Quarkus extensions. They belong in the same module which builds the Quarkus application. See Available BPMS adapters for the list of adapters and their documentation.

  3. io.vanillabp:vanillabp-quarkus-support:
    This dependency contains the VanillaBP APIs you need to compile. Add it to pure workflow modules when you have several modules (see also this note). It keeps workflow modules independent of the concrete BPMS. The APIs of a BPMS are out of reach on purpose, so you cannot break the hexagonal architecture by accident. The module's README lists the APIs it includes. It also brings the integration SPI along. These are the interfaces your application may implement where VanillaBP needs something only your application knows, most of all aggregate persistence.

If the extension is missing, Quarkus reports it, not VanillaBP

vanillabp-quarkus-integration generates the ProcessService beans which your business code injects. It does that while Quarkus augments your application. If you leave that dependency out of the module which builds the application, the build fails during CDI validation with a message from Quarkus:

Build step io.quarkus.arc.deployment.ArcProcessor#validate threw an exception:
jakarta.enterprise.inject.UnsatisfiedResolutionException: Unsatisfied dependency for type
io.vanillabp.spi.process.ProcessService<...> and qualifiers [@Default]
	- injection target: <your workflow service>#processService

In that case, nothing of VanillaBP is on the classpath which could say more. vanillabp-quarkus-support is a plain library, so that a workflow module stays free of platform infrastructure. Quarkus extensions belong to the application. Add the two dependencies above to the application module: the core extension and one adapter per BPMS. If an application HAS the core extension but no adapter, it gets a message from VanillaBP instead, see Available BPMS adapters.

Aligning versions: the VanillaBP BOM

io.vanillabp:vanillabp-bom manages the versions of all VanillaBP artifacts an application uses. Import it once in the parent POM, next to the Quarkus BOM, and leave out 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").

Maven Central lists which patch releases of the 2.0 line exist.

Two things are worth knowing about the BOM:

  • BPMS adapters are not part of the BOM. Each adapter is released independently of the platform integration, so its dependency has its own version (see Available BPMS adapters).
  • The BOM must never be older than your adapters. A version which your build manages always wins over the version an adapter needs transitively. Maven does this without a word, even if it means a downgrade. The build stays green, but the application would fail at runtime. So adapters check the platform version at startup. If it is too old, they stop with a message which names the artifacts to raise. Raising the BOM to the reported version fixes it.

Persisting workflow aggregates

VanillaBP loads and saves your workflow aggregate for you. When a workflow is started, the aggregate has to be saved before the first BPMN task. And every task which the BPMS delivers starts by loading it. Quarkus has no single way to do persistence. So VanillaBP finds out which way the aggregate uses, and serves that one:

The aggregate is What VanillaBP uses
managed by a Panache repository (PanacheRepository/PanacheRepositoryBase) that repository
managed by a MongoDB Panache repository (PanacheMongoRepository/PanacheMongoRepositoryBase) that repository
a Panache active record (extends PanacheEntity/PanacheEntityBase or PanacheMongoEntity/PanacheMongoEntityBase) the entity manager or Panache's operations
managed by a Spring Data repository (extension quarkus-spring-data-jpa) that repository

A repository for the aggregate wins over the aggregate being an active record. Spring Data comes last. You do not have to configure anything for this. The decision is made at build time from the Jandex index, and the build log names the way and the repository chosen for each aggregate. Note: if a workflow module lives in its own Maven module, its classes have to be indexed (see workflow modules in Quarkus). Otherwise the repository is invisible.

For anything else, you provide a CDI bean which implements io.vanillabp.integration.spi.AggregatePersistenceAware<MyAggregate> (see aggregate persistence). Such a bean always wins over the built-in ones. So you can also use it to change what saving means for one aggregate, while all others keep the default.

In two situations the build ends instead of picking something:

  • two repositories manage the same aggregate. Keep one of them, or write the AggregatePersistenceAware which decides,
  • the aggregate uses none of the ways above. The message names what was looked for and where to read about each way.

VanillaBP also calls your persistence from its own threads. A task delivered by the BPMS arrives on a thread of the adapter. The second phase of a workflow start runs on the thread of the outbox dispatcher. On both, VanillaBP opens a JTA transaction and activates the CDI request context before it calls you. So an entity manager works there the same way as in your REST endpoint. Applications written against version 1 or against an earlier 2.0 snapshot may have a @Transactional on their persistence for exactly this reason. You can remove it.

You do not need a relational database. Quarkus always brings JTA. So a transaction is available even in an application without any data source. What that transaction covers depends on the store:

  • MongoDB Panache takes part in it. When you write an entity inside the transaction, Panache starts a MongoDB transaction and binds its session to it. VanillaBP's outbox and delivery log write through the same session. So the aggregate, the outbox entry and the delivery record commit together. MongoDB transactions need the deployment to be a replica set (a single node is enough). On a standalone server, the first write inside a transaction fails, and VanillaBP warns about that at startup.
  • JTA does not cover a storage you bring yourself at all, such as an event store, a ledger, a message producer or a service behind an API. Implement io.vanillabp.integration.spi.TransactionRunner and provide it as a bean. Then VanillaBP opens, commits and rolls back your unit of work instead of the empty JTA one. If one application uses several kinds of persistence, assign them per aggregate with TransactionRunnerAware beans. The workflow-aggregates page shows both ways. It also says how much less is guaranteed where no transaction covers a store.

VanillaBP reads the ID of the aggregate by reflection: a member annotated with @Id, @EmbeddedId or @BsonId, otherwise a member named id. Adapters which store the ID in the BPMS use that name for their variable (e.g. Camunda 8). So if the ID property of an aggregate is called loanRequestId, it shows up under that name in the BPMS.

Configure the transaction outbox

Adapters which connect to a remote BPMS (e.g. a SaaS offering) cannot take part in the local database transaction of your application. This could lead to inconsistencies, for example a workflow started in the BPMS while its workflow aggregate was never stored. To avoid that, VanillaBP starts such workflows in two phases. It plans the second phase through a transaction outbox. That is an entry written in the same local (JTA) transaction as the workflow aggregate. VanillaBP processes it reliably after the commit, even if the application crashes and restarts in between.

Phase two is a moment, not a kind of call. Phase one is your transaction, and phase two is everything which runs after its commit. A call to a BPMS is one kind of work in phase two. An operation which an extension registered for itself is another. Both are written into the same outbox, in the same transaction, and both run after it committed.

The embedded engine Camunda 7 takes the same path, although it could join your transaction. The reason: an operation which loses a concurrency conflict cannot be repeated inside the caller's transaction. Repeating only the engine part would move the workflow on while your data rolls back. So an application on Camunda 7 needs an outbox as well (see the adapter's wiki).

This works without any setup:

  • JDBC (quarkus-agroal plus a JDBC driver extension): entries are stored in the table VANILLABP_PHASE_TWO_OUTBOX. If an entry carries a payload, the payload goes into VANILLABP_PHASE_TWO_OUTBOX_PAYLOAD. The table VANILLABP_HOUSEKEEPING records which node removes the old rows of a store tonight. All three tables are created automatically, and all three belong to io.vanillabp:vanillabp-schema. A Spring Boot application on JPA runs the same store, written once for both platforms. A payload is deleted when its entry is dispatched. It is deleted again together with the entry when vanillabp.outbox.retention has passed. An entry which still waits, or which is blocked, keeps its payload as long as it is in the table. The housekeeping deletes by age only payloads which no entry names any more. Such payloads are left behind by a write which never committed.
  • MongoDB (quarkus-mongodb-client incl. quarkus.mongodb.database): entries are stored in the collection vanillabp-phase-two-outbox. If an entry carries a payload, the payload goes into vanillabp-phase-two-outbox-payloads. Both collections belong to VanillaBP. MongoDB creates a collection with the first write, so neither has to exist before. vanillabp.outbox.create-schema decides whether VanillaBP creates the indexes for reading them at startup. A payload is deleted when its entry is dispatched. It is deleted again together with the entry when vanillabp.outbox.retention has passed. An entry which still waits, or which is blocked, keeps its payload as long as it is in the collection. The housekeeping deletes by age only payloads which no entry names any more. Such payloads are left behind by a write outside a MongoDB transaction, when that transaction does not commit. A backup should cover both collections, because an entry restored without its bytes cannot be dispatched.

The outbox does not need the quarkus-scheduler extension for its background work.

The outbox is chosen per workflow aggregate. The entry always goes with the transaction which stores the aggregate. VanillaBP checks at startup that a needed outbox is available. If the adapter with the first priority for a process needs the two-phase start and no outbox can be found, the application does not boot. The message names the ways to fix it.

An application may have two persistences: a relational database and MongoDB. That happens during a migration, or in a workflow module which holds one aggregate of each. Then both defaults are active. VanillaBP assigns them by the persistence it found for each aggregate. A Panache or Spring Data aggregate is served by the JDBC outbox, a MongoDB Panache aggregate by the MongoDB outbox. You do not have to configure anything for that. If a single default clearly does not match an aggregate, the boot ends. Otherwise the entries would be written next to the aggregate's transaction instead of into it.

Two cases are up to you. The first is an aggregate stored by an AggregatePersistenceAware of your own. There the platform cannot know which store its transaction reaches. The second is an aggregate which you want served by an outbox of your own. For both, write a CDI bean which implements io.vanillabp.integration.spi.PhaseTwoOutboxAware<MyAggregate>. It wins over the automatic assignment.

You can tune the outbox with these properties (defaults shown):

vanillabp:
  outbox:
    # the longest the background poller sleeps while nothing is due. It is a
    # cap and not a rhythm: the poller asks the store when its earliest
    # unfinished entry is due and sleeps until exactly that moment, and after a
    # commit the entry is dispatched at once. Raise it and an application
    # waiting in a timer stops asking its database at all - see "How late an
    # entry can be dispatched"
    poll-interval: PT10S
    # the distance to the FIRST retry after a failed attempt; every further
    # attempt doubles it until max-attempt-frequency is reached
    attempt-frequency: PT30S
    # the longest distance the growing backoff reaches, so a BPMS which comes
    # back is noticed within it however long it was away
    max-attempt-frequency: PT5M
    # after how many failed attempts an entry is blocked (manual cleanup
    # required); fifty of them span an outage of about four hours
    block-after-attempts: 50
    # how long an entry may wait for a BPMS which does not report its
    # workflow yet, counted from the moment it was written; such a wait uses
    # no attempts. Not set by default, which means the time the attempts above
    # take, about four hours
    # wait-for-visibility-at-most: PT4H
    # how many entries a store dispatches at the same time; the workflow
    # aggregate decides which thread takes an entry, so two operations of one
    # workflow keep their order while different workflows travel at once. The
    # JDBC store and the MongoDB store both read it
    dispatch-threads: 4
    # how long successfully dispatched entries (marked DONE) are kept before
    # they are deleted asynchronously - see "What the outbox guarantees"
    retention: P7D
    housekeeping:
      # when the outbox removes what it does not need any more: the dispatched
      # entries whose retention passed and the payloads no entry names. Inside
      # the window it works off as much as fits, outside it neither costs
      # anything - see "When the outbox throws things away"
      start: 04:00
      end: 05:00
      # the zone the two times above are read in. Unset means the zone of the
      # JVM, and a JVM standing on UTC without this key is warned once at the
      # startup - it starts either way
      #zone: Europe/Vienna
    # whether to create what the outbox reads automatically - both tables,
    # or both collections on MongoDB, and the indexes on them; disable if
    # the database schema is managed manually (e.g. Flyway/Liquibase). On
    # JDBC both tables are then checked at startup instead
    create-schema: true
    jdbc:
      # disable the JDBC default (e.g. if you define your own outbox)
      enabled: true
      # custom table name (the table is created automatically with this name)
      #table: VANILLABP_PHASE_TWO_OUTBOX
      # custom table name for the payloads; unset it follows the table above
      #payload-table: VANILLABP_PHASE_TWO_OUTBOX_PAYLOAD
      # custom name of the table the records of processed task deliveries
      # live in; unset it is VANILLABP_TASK_DELIVERY, and unlike the payloads
      # it does not follow a renamed outbox
      #delivery-table: VANILLABP_TASK_DELIVERY
      # custom name of the table the housekeeping takes its claim in; unset it
      # is VANILLABP_HOUSEKEEPING, and like the deliveries it does not follow a
      # renamed outbox
      #housekeeping-table: VANILLABP_HOUSEKEEPING
    mongo:
      # disable the MongoDB default (e.g. if you define your own outbox)
      enabled: true
      # custom collection name
      collection: vanillabp-phase-two-outbox
      # custom collection name for the payloads; unset it follows the collection above
      #payload-collection: vanillabp-phase-two-outbox-payloads
      # custom name of the collection the records of processed task
      # deliveries live in
      delivery-collection: vanillabp-task-deliveries
      # custom name of the collection the housekeeping takes its claim in
      housekeeping-collection: vanillabp-housekeeping

The store of the payloads is named after the outbox it belongs to. If you set no name for it, VanillaBP adds _PAYLOAD to the table name and -payloads to the collection name. So if you rename the outbox, the payloads are renamed with it. Two applications on one schema need exactly that. They keep apart by the name of the outbox. If the payloads stayed behind, the housekeeping of one application would delete rows of the other.

Each of these names has to be different. The outbox, the payloads behind it and the records of delivered tasks are in the same database. So if two keys name the same table or collection, the startup ends and says which two keys they are. Table names are compared in capitals, the way a relational database looks them up. Collection names are compared as you wrote them.

Applications which use another persistence technology (or an outbox infrastructure they already have) can define their own CDI bean which implements io.vanillabp.integration.spi.PhaseTwoOutbox. As soon as there are several outboxes, you have to assign an outbox to specific aggregates. This is also how you move a high-load process to a store of its own. Define a bean which implements PhaseTwoOutboxAware<MyAggregate> and returns the outbox to use. The implementation with the most specific aggregate class wins, as for aggregate persistence. Every outbox instance needs its own store (table/collection). Two dispatchers polling the same store would compete and dispatch entries twice.

A custom store stores 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 stored values back to VanillaBP's router, which works out the operation. Operation names may have a namespace (my-extension:NOTIFY), because extensions add operations of their own. So store the name as text of at least 255 characters.

When the outbox throws things away

Two kinds of row leave the store: the entries which were dispatched longer ago than vanillabp.outbox.retention, and the payloads which no entry names any more. Both used to be removed at the end of every poll, so every application paid for them all day long. Now this happens in a window at night, from vanillabp.outbox.housekeeping.start to .end. That is four to five in the morning unless you set other times. Inside the window the store does as much as fits. Outside the window, removing rows costs nothing. (why)

You do not set how much fits. The first batch of a night is a thousand rows. VanillaBP measures how long it took. The next batch is twice as large, as long as twice that time still fits in the rest of the window. A batch which runs past the end halves the size. The next night starts at half of the largest batch which fitted, because your database will not be the same at four in the morning tomorrow.

Only one node does the housekeeping of a store per night. If two nodes did it at once, each one would measure the other's work. So a node writes a claim into the store's own database, in the table VANILLABP_HOUSEKEEPING or the collection vanillabp-housekeeping. The other nodes leave that store alone until the window closes. The claim is per STORE. So in an application which runs a JDBC outbox and a MongoDB outbox side by side, two different nodes may do the housekeeping.

A node which is down for the whole window does no housekeeping that night. A pod scaled to zero at night, or one which is being rolled out at that moment, simply skips it. VanillaBP has no mechanism to catch up, because what such a mechanism should do would be a guess. The next night takes the backlog with it, and the three meters below show it happen.

Three meters say whether the window is wide enough: vanillabp.outbox.housekeeping.remaining is what the window did not get to, .removed is how many rows it removed, and .window.used is how much of the window it needed. All three are set when the window closes, and they stay until the next one closes. Read them together. If the window was used up AND left something behind, the window is too small, and you should make it wider. The remainder alone would not tell you that. A store with work left may also be one which never did its housekeeping, because the node was down.

The window is read in a time zone, and an application on UTC is told so once. A container runs on UTC unless somebody sets its zone. "Four in the morning" then means four UTC, which in most places is the middle of the working day. Such an application starts, and VanillaBP warns while it starts. The line names the hours at which the window really runs and the zone of the JVM. It also names two ways to choose another zone, and neither needs a new build. Set the zone of the container (TZ=Europe/Vienna), or set the environment variable of the property (VANILLABP_OUTBOX_HOUSEKEEPING_ZONE=Europe/Vienna). Write UTC there if you mean UTC, and the line goes away. It stays a warning because UTC is the normal case for a container. A refused start would cost you a deployment. A window in the wrong zone only costs a cleanup at an unexpected hour.

What the outbox guarantees

  • Every planned operation carries an idempotency key. The store keeps the key unique AMONG THE ENTRIES STILL WAITING for their dispatch. So if the same operation is planned again before the first one reached the BPMS, nothing happens. The key says that 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 gets a key only if you pass a correlation ID. Without one, the same message may be correlated again, and that is allowed. The key names its operation, so completing and cancelling one task no longer share it. A workflow start by message is the one exception, on purpose: it shares the key of the plain start.
  • A younger call may take the place of one still waiting. The rule above drops the second call. That is what a call which names an operation wants. A call which carries a payload wants the opposite. The payload is a state, and the older entry holds the older state. So such a call may ask to replace the entry instead, see work for after the commit. A store of your own which does not support this keeps the older state and writes a warning which names the store. Implement PhaseTwoOutbox.scheduleReplacingWhatIsStillWaiting to support it. Never replace an entry which a dispatch has already taken. (why)
  • A dropped plan is logged as a warning. The warning names what was not planned, and both causes which VanillaBP cannot tell apart. Either something which already happened was dispatched again, or a second valid 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 in the background after vanillabp.outbox.retention (7 days by default). That keeps them readable for support. It does not make the deduplication window longer, which ends with the dispatch. Make it shorter if the store grows too much, but do not set it to zero. Making it shorter no longer costs anything on the inbound side. The records of delivered tasks have their own vanillabp.delivery.retention (see below). It follows this number only if you do not set it.
  • Dispatch is at-least-once. The application may crash after the BPMS accepted an operation, but before the entry is marked DONE. Then the entry is dispatched again after the restart. For workflow starts, VanillaBP first probes the recorded BPMS. If the workflow is already known, it consumes the entry, so a duplicate start is unlikely. The window is small, but not closed, so never build on exactly-once. Your @WorkflowTask methods should be idempotent anyway (see Workflow tasks).
  • Entries which keep failing are blocked after block-after-attempts attempts. Then somebody has to look at them, see how long it tries, and how to open a blocked entry again. Watching the outbox store for blocked entries is a job for operations. Alert on vanillabp.outbox.blocked (the metrics). The gauge of waiting entries falls when an entry is blocked, so on its own it would look as if the backlog had cleared.
  • A failure which the BPMS answers the same way every time is blocked at once. An adapter may report a failure as permanent (MigratableProcessService.isPhaseTwoFailureRepeatable), for instance a command which 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. The error names the operation, the workflow module, the BPMN process and the aggregate. Everything else stays repeatable, and that is why losing a concurrency conflict does no lasting harm. (why)

Both stores of this platform belong to VanillaBP, and they keep the guarantees above in the same way: by the unique DEDUP_KEY column or dedup field, and by the status and attempt count of the entry. A Spring Boot application on JPA runs the very same JDBC store, written once and shared by both platforms.

How late an entry can be dispatched

The poll interval used to answer this question, but it does not any more. The poller asks its store when the earliest entry it still owes something to is due, and it sleeps until exactly that moment. So an entry goes out at its due time, whatever poll-interval says. After a commit, the entry goes out at once, as it always did. The interval limits only one case: work which a node wrote down before it went away. The node which writes an entry dispatches it itself, so nothing tells the other nodes about that row. The limit is what makes somebody look anyway (see running more than one node).

So the number controls cost, not latency. Left at ten seconds, an application keeps exactly the timing it always had. Set to ten minutes, an application with nothing to do pays one small query per store every ten minutes, instead of 8640 polls a day. The only thing it gives up is how soon the work of a node which died is picked up. Setting it back to seconds because an entry looked late buys nothing. That entry was waiting for its own due time, not for the poller.

When you change it, the startup log says what else is still awake. Read it before you conclude that the database has gone quiet. The retention cleanup of the task delivery records still looks once an hour, and only after a delivery was recorded. The gauge which counts the entries waiting in an outbox asks for them while your monitoring scrapes, at most once per vanillabp.metrics.gauge-cache. And how an adapter finds work for your handlers is up to that adapter, and its own documentation explains it.

How long it tries, and how to open a blocked entry again

A failed dispatch is retried at a growing distance: attempt-frequency for the first retry, doubled for every attempt after it, up to max-attempt-frequency. With the defaults that is thirty seconds, then one minute, two, four, and five minutes from there on. That makes fifty attempts in total, which is close to four hours of trying. The numbers answer two questions at once. The limit says how long after a BPMS comes back the first entry reaches it: five minutes at worst. The attempts say how long an outage the store survives without anybody's help. Four hours is a cluster upgrade, and that is the length they were chosen for. If your BPMS will be down longer than that, raise block-after-attempts before the maintenance, not after it.

When a node has claimed an entry, that node holds it, and the hold lasts longer than the distance above. A poll claims an entry by writing down which node took it and how long the claim lasts: one attempt-frequency. From the claim on, the node keeps moving that moment forward until the dispatch is over. This starts at the claim and not at the dispatch, because an entry first waits for the thread its aggregate travels on. A wait longer than the claim would hand the entry to the next poll. So an operation which queues for its thread and then needs a minute against a slow BPMS is carried out once, not once per attempt-frequency. No second node picks it up in the meantime. If the node which carries it disappears, nothing moves the moment forward any more. The claim runs out, and the next poll of any node takes over the entry. That is the recovery which was always there, now with its own deadline instead of one tied to the retry distance.

block-after-attempts counts attempts, not claims. An attempt is counted when it ENDED, with whatever result it had. So an entry which travels for a long time uses nothing of the budget, and an entry which keeps failing uses one per failure. A slow BPMS is therefore never the reason why an entry is blocked. In the table the columns are LEASED_BY and LEASED_UNTIL, in the MongoDB collection the fields leasedBy and leasedUntil. Both are empty as long as nobody holds the entry.

The growing distance applies to a dispatch which FAILED. An entry whose workflow the BPMS has not made searchable yet is a different case. It gets its own due time, which is the adapter's window and not this backoff.

Waiting for a read model is not an attempt. Sometimes the BPMS does not report the workflow of an entry yet, for example a Camunda 8 cluster whose exporter is behind. Such an entry uses none of block-after-attempts. It comes back after the adapter's window as often as needed. It is blocked only when wait-for-visibility-at-most has passed since it was written. That key is not set by default. Then it is the time which the attempts take with the growing backoff, about four hours. So a read model which stopped gets as much time as a BPMS which is away. Counting each answer as an attempt used to block entries after fifty windows, which were eight minutes of a stopped exporter. And those entries did not come back when the exporter caught up. An entry which was blocked this way says so in its ERROR, with the time it waited.

Every store uses the window exactly as the adapter named it. It is not read as an upper limit. A window longer than attempt-frequency delays the entry, and a shorter one brings it forward. The adapter knows when its BPMS can answer, and asking earlier only asks in vain. So the same adapter answers the same way, whichever store you chose, here and on Spring Boot. That is why an adapter's documentation can name a number at all. A store adds no wait of its own on top. Its poller sleeps until the earliest entry is due, and it learns the new due time when the attempt ends. poll-interval only limits that sleep.

A blocked entry stays in the store, and the retention never touches it, because the retention deletes entries which are DONE. The operation still has to happen, and an entry deleted before it is finished is a workflow which silently never goes on. But a blocked entry no longer stands in the way of its own repetition. Blocking releases the deduplication key, exactly as a dispatched entry releases it. So the application can plan that operation again, and the new attempt is an entry of its own. Whoever repairs the blocked row has to expect a second row for the same operation. And the counter vanillabp.outbox.discarded no longer rises in this case, because there is nothing left to drop.

A blocked entry says why. The column LAST_FAILURE (in the MongoDB collection the field lastFailure) holds the reason of the last attempt which did not get through. That is the class and the message of what the dispatch threw, and of its causes, in one line and cut to 1000 bytes. The stack trace stays in the log. Every attempt writes the reason, and an attempt which gets through empties it. So an entry which is still being repeated says why as well. Blocked outbox entries has the query which reads it.

The payload of a blocked entry stays with it. The retention counts from the entry. It deletes a dispatched entry together with the bytes which that entry named. It never takes the bytes of an entry which is still waiting or blocked. So an entry which is opened again weeks later still carries the state its caller planned it with. The cleanup by age deletes only payloads which no entry names any more. Such payloads are left behind by a write which never committed.

Keeping those bytes costs something while the entry stays blocked. Before the housekeeping removes a payload by age, it asks whether an entry still names it. It asks in the database: one statement per batch, and nothing of either set travels through your application. On MongoDB the entries are joined with a $lookup, using the index on the reference inside the entry document. On a relational database the outbox table has a column PAYLOAD_REFERENCE with an index on it, so the question is a lookup there too. We measured it on PostgreSQL 16.15 on 2026-09-25. With a hundred blocked entries, one pass took 1 ms, no matter whether ten thousand or a million dispatched entries were next to them. With ten thousand blocked entries it took 20 ms. Repairing the blocked entries makes the cost go away, and you should do that anyway.

How to find a blocked entry, open it again or delete it, in the table and in the MongoDB collection, is on its own page: Blocked outbox entries. The ERROR which reports a blocked entry links there too. That page also says what to check before you open an entry, because by then the operation may have a younger entry.

What VanillaBP remembers about delivered tasks

This is the inbound side of the outbox. A remote BPMS may hand out the same task again. VanillaBP records what it processed. So it answers a repeated delivery from that record and does not run the handler twice (see Workflow tasks). The store is chosen per workflow aggregate, exactly like the outbox. So a record always goes with the aggregate's own transaction:

  • With quarkus-agroal, the records live in a table VANILLABP_TASK_DELIVERY. The connection is taken inside the running JTA transaction, so it is enlisted there. If your database has naming rules, vanillabp.outbox.jdbc.delivery-table gives the table a name of your own, the way table and payload-table do it for the outbox. The same table answers which BPMS holds a task when you complete it. That is what its TASK_ID column and the index on it are for.
  • With quarkus-mongodb-client, they live in a collection vanillabp-task-deliveries of quarkus.mongodb.database, keyed by the delivery. If your database has naming rules, vanillabp.outbox.mongo.delivery-collection gives the collection a name of your own, the way collection and payload-collection do it for the outbox. If MongoDB Panache registered a session for the running transaction, the record joins it and commits with the aggregate. Without one, the record is written at once and removed again if the transaction does not commit. That is the same best-effort compensation which the MongoDB outbox does.
  • The STORE settings are those of the outbox, but the retention is not: vanillabp.outbox.create-schema decides whether VanillaBP creates the table or the index itself. vanillabp.outbox.jdbc.enabled / .mongo.enabled switch the defaults off. vanillabp.outbox.mongo.delivery-collection names the MongoDB collection. And vanillabp.delivery.retention decides how long a record is kept after the BPMS last asked about it. That last one is a property of its own, because it decides correctness, not how long support can read along. A delivery which arrives later than the retention finds no record and runs your handler a second time. On the outbox side, the deduplication window ends with the dispatch, and the retention only keeps a dispatched entry readable. If you do not set it, it follows vanillabp.outbox.retention, where the number lived before the two were separated. VanillaBP says at startup which of the two applies. It is read for the whole application, not per workflow module, because one cleanup per store deletes by age across the whole table. The BPMS asks about a task your application leaves open until somebody completes it. So the record of that task stays, while the retention deletes the records of the tasks which are done.
  • For any other persistence, write a bean of your own which implements io.vanillabp.integration.spi.TaskDeliveryLog. Assign it to aggregates through io.vanillabp.integration.spi.TaskDeliveryLogAware<MyAggregate>. With both defaults active, the store is assigned by the aggregate's persistence, exactly like the outbox. So you only need that assignment if you brought the persistence yourself or want a store of your own. 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. If the record of an open task has to survive in the same way, implement stillOpen. VanillaBP calls it with the key of every repeated delivery it answers.
  • vanillabp.delivery.release-on-workflow-end (default false, can be changed 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. If a store of your own does not, the startup names it together with the property.

Creating the tables with Liquibase or Flyway

In some applications the schema is a reviewed artifact which the deployment pipeline applies. Such an application 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 in your relational database exist. If one is missing, it ends the boot (why) and names the table, the property and the artifact to apply. That is better than failing at the first workflow start, hours after the deployment.

The statements come from the artifact io.vanillabp:vanillabp-schema. It contains nothing but schema definitions. So a repository which holds your migrations can depend on it without pulling in 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 copy of the changelog. Set vanillabp.outbox.table, vanillabp.delivery.table, vanillabp.payload.table or vanillabp.housekeeping.table as a changelog parameter. Write the same name on both sides, or the runtime looks for a table which the changelog did not build. The runtime keys are vanillabp.outbox.jdbc.table, vanillabp.outbox.jdbc.payload-table, vanillabp.outbox.jdbc.delivery-table and vanillabp.outbox.jdbc.housekeeping-table.

The generated Flyway files cannot follow a name of yours. They are generated while io.vanillabp:vanillabp-schema is built. Nobody knows your names then, so they carry the defaults. If you rename a table, apply the changelog with Liquibase, or edit the generated statements before you apply them.

The changelog also carries the indexes, and they are not decoration. Each time the poller wakes up, it asks its store when the earliest entry it owes something to is due. Without an index, that question reads the whole table. We measured it on PostgreSQL 16 with 200000 rows: 11 to 18 ms without the index against 0.05 ms with it. The first number grows with the table, the second does not. The outbox table gets three indexes. Each one filters the same status and then reads a moment of its own: when the next entry is due, what the retention may delete, and how long the oldest waiting entry has already waited. The last one answers the gauge vanillabp.outbox.oldest.pending.age. We measured it on PostgreSQL 16.15 in September 2026. Without the index it took 0.17 ms with a backlog of 1000 entries, 28 ms with 100000 and 78 ms with 500000. With the index it took 0.07 ms at every size. The table which holds the payloads has its own index, on the moment a payload was written. The delivery table gets one for what its retention deletes by, plus the ones its reads need. An application which applied an earlier version of the changelog gets the indexes from the changeset of the version which added them. If VanillaBP created the tables of an application, the startup says which index is missing and which statement adds it. Creating an index on a large table locks it, so that is your decision and not something a boot should do behind your back.

With Flyway, the advice for Spring Boot does not work on this platform. That advice is one instance and one history table per owner of schema. Read this before you copy it:

  • the extension applies ONE configuration per datasource. A named configuration without a datasource of the same name is skipped without a word, even if it has a jdbc-url of its own.
  • a migration run of your own comes too late. Hibernate builds its session factory before any StartupEvent observer runs. With quarkus.hibernate-orm.schema-management.strategy: validate, it also compares the session factory against the schema at that time.

So a history per owner costs one of two things. Either you need a datasource per history, whose pool exists for a single migration and is never used again. Or you lose the validation at startup. What works instead is one timeline for everybody, with separate version ranges:

quarkus:
  flyway:
    locations: classpath:vanillabp/schema/flyway/h2,classpath:loan-approval/db/migration,classpath:db/migration
    migrate-at-start: true

VanillaBP numbers its own migrations in its own version range (V2.0.0 today). Leave that range alone, and an application and its workflow modules can number theirs freely. Flyway does not know who wrote a migration. But a clash is loud, not silent: the boot ends with Found more than one migration with version X.

Which databases: tests cover H2 and PostgreSQL. MySQL, MariaDB, SQL Server, Oracle and DB2 ship as well, generated by Liquibase from the same changelog, but nothing here runs them. It is the same list the Camunda 7 engine serves, so VanillaBP never supports fewer databases than the engine. Oracle and DB2 count a VARCHAR in bytes. What that means for the ID of your aggregate is in Workflow aggregates.

On Quarkus, all four tables VanillaBP writes into belong to VanillaBP. So the artifact covers everything except the Camunda 7 engine tables. Those ship with the engine, as a Liquibase changelog (org/camunda/bpm/engine/db/liquibase/camunda-changelog.xml) and as plain create scripts.

The blueprints persistence-liquibase and persistence-flyway build and test both ways. Each has a Quarkus variant, and its configuration explains the reasons above.

The same switch on MongoDB names indexes, not tables

vanillabp.outbox.create-schema reaches the MongoDB stores as well, but there it means something else. MongoDB creates a collection with its first document. So there is no collection to create, and none to check either. What VanillaBP would have created are the indexes which answer its questions. With the switch off, they are your job:

db.getCollection("vanillabp-phase-two-outbox").createIndex({ "dedupKey": 1 }, { "unique": true });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "status": 1, "nextAttemptAt": 1 });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "status": 1, "doneAt": 1 });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "status": 1, "createdAt": 1 });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "args.payloadReference": 1 }, { "sparse": true });
db.getCollection("vanillabp-phase-two-outbox-payloads").createIndex({ "createdAt": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "lastSeenAt": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "taskId": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "aggregateId": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "workflowId": 1 });

These are the default names. If you gave a collection a name of your own with vanillabp.outbox.mongo.collection, vanillabp.outbox.mongo.payload-collection or vanillabp.outbox.mongo.delivery-collection, use that name instead.

You do not have to copy this list to find out what is missing. At startup, VanillaBP reads which indexes each of the three collections has. It warns about every missing index, with the statement above and a word on what reads it. That stays a warning. The application boots, and until the index exists, the question reads the whole collection. That costs more the longer the application runs. Act on the unique index on dedupKey first. It is not about speed. Without it, two nodes which write the same operation at the same moment both plan it, and the BPMS is called twice.

Configure the adapters

Each BPMS the application uses is configured as a named adapter below vanillabp.adapters. The key is the adapter ID, which names one instance of a BPMS. Its type names the BPMS adapter which serves it.

Zero configuration: the classpath IS the configuration

An application with one BPMS adapter extension and one workflow module needs no vanillabp.* property at all. VanillaBP works out everything it can by itself:

  • the adapter: the single adapter type found in the classpath becomes the one configured adapter (its ID is the adapter type). Because it is the only one, it is also 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.

You only have to write the settings which the BPMS itself needs (e.g. the address of a remote cluster). They go into 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. You need vanillabp.adapters.<id>.type only for a custom adapter ID (old-bpms, c7-legacy, …) which does not match an adapter type. Custom IDs let you run several instances of the same BPMS. VanillaBP can never derive them, so they always need their type. This default exists for applications which upgrade from VanillaBP 1, where there was one adapter and its ID was its type. It is not a recommendation. A good ID says WHICH system it is (camunda8-saas next to camunda8-onprem, camunda7-loans next to camunda7-claims). An operator reads it 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 stores it to know what belongs to which BPMS. See naming adapter ids, and never renaming them.
  • A single configured adapter is the prioritized adapter. You may leave out vanillabp.prioritized-adapters as long as exactly one adapter is configured.
  • vanillabp.retired-adapters names the adapter ids which this application USED to have. At startup, VanillaBP asks its stores for which ids they still hold waiting outbox entries or records of open tasks. It names any such id which is no longer configured. Either the id was renamed, which loses workflows, or it was removed too early. Naming it here says that you know about the leftovers, see naming adapter ids, and never renaming them.

Quarkus limitation (only for CUSTOM adapter IDs): a section must have at least one property. The configuration binder of Quarkus finds the configured IDs from the property names below vanillabp.adapters. So an entry without any property (my-c7: or my-c7: {}) gives no name and is dropped without a word. The ID never reaches VanillaBP. So VanillaBP cannot point at the real cause, and it adds a note which explains exactly this to its guiding messages. An ID which is an adapter type comes from the classpath anyway. So this only affects custom IDs, and those need their type in any case:

vanillabp:
  adapters:
    my-c7:
      type: camunda7

Several BPMS: naming the order is the whole configuration

With several adapter extensions 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 you need:

vanillabp:
  prioritized-adapters:
    - camunda8    # new workflows start here
    - camunda7    # existing workflows complete here

VanillaBP derives the adapter sections from that list. You have to write down everything which differs from the convention. A custom ID needs its type. Two IDs of the same type must be distinguishable. What that means is up to the adapter, e.g. separate databases or separate cluster endpoints.

Several BPMS: explicit IDs and priorities

vanillabp.prioritized-adapters defines the order in which adapters are used. You can set again which adapter is asked first for a workflow module and for a single workflow: the levels a setting is written at. New workflows always start in the first adapter of the list which counts for them. Existing workflows are found by asking the adapters in order of priority (cached after the first successful election). That is what makes BPMS migration possible 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

Settings of one adapter (e.g. the connection to a remote BPMS) go into the SAME section. Each adapter adds its own keys to vanillabp.adapters.<id>.* (see your adapter's wiki for its keys). There is no separate namespace per adapter. A key which differs by scope may be written again for a workflow module, for a workflow and for a single task: the levels a setting is written at.

deployment-failure: warn only works for adapters which are not first in the priority list, neither for the workflow module nor for any of its workflows. If an adapter has the first priority anywhere, a failed deployment to it always stops the boot. Otherwise new workflows could not be started.

Migrating a single workflow (workflow-level overrides)

You can override the prioritized adapters for a single workflow (BPMN process) of a workflow module. This is how you move 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 resources of the workflow module are deployed to every adapter named at the module level or at any workflow level. Each such adapter gets all the resources of the module, because deployment works per file while the election works per process. So an adapter named only for a single workflow still gets the deployment it needs to start that workflow.
  • Validation: a workflow ID below workflows may match no executable BPMN process of the module. That does not stop the boot, because the BPMN may arrive later during a migration. But a warning at startup reports it and names the known BPMN process IDs. If a prioritized-adapters entry at workflow level names an unknown adapter ID, the boot stops, as on the other levels.

BPMN resources

At startup, VanillaBP reads all .bpmn files below the configured resources location of each workflow module. It wires them to the business code and deploys them to the BPMS of every prioritized adapter, exactly as on Spring Boot. What is special on Quarkus:

  • Only classpath locations are supported, with classpath: or classpath*: (e.g. the convention classpath*:loan-approval/processes/camunda8, see Where VanillaBP looks for BPMN files). The BPMN files are indexed at build time, because a packaged Quarkus application cannot scan patterns like **/*.bpmn at runtime. A file: location fails the startup with a message which guides you.
  • In dev mode, a change to a BPMN file triggers a hot reload. So does a new BPMN file which you add while the application runs.
  • On Quarkus, workflow processing starts before the application serves traffic (the StartupEvent of Quarkus). Spring Boot starts it when the application is fully ready (ApplicationReadyEvent). On a graceful shutdown, both platforms stop the workflow processing of extensions and adapters in the reverse order of their start.

Overriding configuration via environment variables

You can override all vanillabp.* properties with environment variables (e.g. VANILLABP_ADAPTERS_CAMUNDA8_DEPLOYMENT_FAILURE=warn). One limit applies on all platforms. An environment variable can only override an adapter or workflow module which a configuration file already declares. It cannot introduce a new entry whose ID contains dashes or dots. The binder cannot rebuild the ID from the variable's name (C8_CLOUD could mean c8-cloud, c8.cloud or a nested key). VanillaBP does not silently ignore such a variable. It fails the startup with a message which names 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.

Look twice at a key with a dash in it, because the name of a variable is not the name of the property. Quarkus turns everything which is neither a letter nor a digit into an underscore and writes the name in capitals. So vanillabp.adapters.camunda8.rest-address becomes VANILLABP_ADAPTERS_CAMUNDA8_REST_ADDRESS. The spelling without the dash, VANILLABP_ADAPTERS_CAMUNDA8_RESTADDRESS, reaches nothing. One rule covers every key, wherever the dash is.

Spring Boot reads the other spelling. There, the form which always works drops the dash (see the same section on the Spring Boot page). So an application deployed on both platforms, or a pipeline which runs one example on both, sets two variables for one property. Nothing reports the wrong one. A variable which matches no property is not read, and the setting keeps the value from the configuration file.

Building a native image

The extension builds as a native image. You do not have to configure anything for it:

mvn package -Dquarkus.native.enabled=true -Dquarkus.native.container-build=true

VanillaBP puts the BPMN files of your workflow modules and the configuration files they ship into the image for you. So a binary reads the settings of a module and its profile-specific files exactly as the JVM does. What a profile still decides after the image exists, which file a binary does not read, and what your BPMS adapter adds to all of this: running a VanillaBP application as a native image.

Clone this wiki locally