Skip to content

Migrating from version 1

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

This page is for teams that run a VanillaBP 1 application. It lists every change you have to make, with before and after. It also lists the behaviors that differ even where your code does not change.

In short: your business code stays. Dependencies, configuration and a few behaviors change.

  1. What stays as it is
    Everything you do not have to touch, above all your business code.
  2. Dependencies
    The runtime versions, the renamed artifacts, and why persistence needs no change.
  3. Configuration
    default-adapter becomes an order, the module descriptor, the adapter sections, and every key one by one.
  4. Code changes
    The three removed overloads, @BpmnProcess.primary(), and the annotation you remove from your workflow services.
  5. Behaviors that differ
    What behaves differently even where your code stays the same, and the one case in operation which version 1 did not explain.
  6. Business Cockpit
    The renamed adapter artifacts, the moved keys, and the workflow module which quietly stops reporting.
  7. Migrate in six steps
    The order in which to do all of the above.

1. What stays as it is

  • Your business code. @WorkflowService, @WorkflowTask, @TaskId, @TaskParam, @TaskEvent, the multi-instance annotations and ProcessService keep their meaning. No method changed its signature, except for the three removed overloads (below).
  • Your workflow aggregates, their persistence and their relation to the workflow: one aggregate per workflow instance, no process variables.
  • Your BPMN models, per BPMS (business process management system). The wiring conventions are unchanged. Camunda 8 models even stay byte-identical. VanillaBP 2 injects the same zeebe:taskListeners and subscription correlation keys as version 1 did, so the upgrade deploys no new process version. An adapter may still add something of its own to a model, and the Business Cockpit does. See below.
  • Your workflow modules as a concept, their ids and their configuration files per module (<module-id>.yaml/.properties). What changes is how you DECLARE a module, see the descriptor.

2. Dependencies

Runtime

Version 1 Version 2
Java 17 21
Spring Boot 3.x 4.1
Quarkus not yet supported 3.37

Spring Boot 4 split spring-boot-autoconfigure into modules, so some Spring imports in your own code move. One example is @EntityScan, which moves from org.springframework.boot.autoconfigure.domain to org.springframework.boot.persistence.autoconfigure. The test slices also need explicit dependencies now. This is a change in Spring, not in VanillaBP. Follow Spring Boot's own migration guide for it.

Artifacts

The adapter artifacts were renamed (<bpms>-spring-boot-adapter → <bpms>-adapter-spring-boot). Every adapter now also has a Quarkus artifact:

<!-- before (Spring Boot, Camunda 7) -->
<dependency>
  <groupId>org.camunda.community.vanillabp</groupId>
  <artifactId>camunda7-spring-boot-adapter</artifactId>
  <version>1.x.x</version>
</dependency>

<!-- after -->
<dependency>
  <groupId>org.camunda.community.vanillabp</groupId>
  <artifactId>camunda7-adapter-spring-boot</artifactId>
  <version>2.0.0</version>
</dependency>
BPMS Spring Boot Quarkus
Camunda 7 org.camunda.community.vanillabp:camunda7-adapter-spring-boot org.camunda.community.vanillabp:camunda7-adapter-quarkus
Camunda 8 org.camunda.community.vanillabp:camunda8-adapter-spring-boot org.camunda.community.vanillabp:camunda8-adapter-quarkus

On Spring Boot the adapter brings in the platform integration as a transitive dependency, so one dependency is enough. On Quarkus both VanillaBP and the adapter are Quarkus extensions, so you add both as dependencies (see Quarkus integration).

Some application modules must not depend on a specific BPMS. They use the support modules io.vanillabp:vanillabp-spring-boot-support / io.vanillabp:vanillabp-quarkus-support. These carry the VanillaBP APIs such a module needs to compile, and no API of any BPMS. They also bring along the integration SPI (io.vanillabp:vanillabp-integration-spi) as a transitive dependency. SPI means service provider interface. Here it is the set of interfaces your application may implement where VanillaBP needs something only your application knows. Aggregate persistence is the main case.

Persistence needs no dependency change

Version 1 stored workflow aggregates through Spring Data, JPA or MongoDB, and a repository for the aggregate had to exist. Version 2 does both out of the box, so nothing changes here. On Quarkus the built-in support covers more than before. VanillaBP itself persists an aggregate which is managed by a Panache repository, written as a Panache active record (JPA or MongoDB), or managed by a Spring Data repository.

Your own persistence is new in version 2. Its SPI, io.vanillabp.integration.spi.AggregatePersistenceAware, did not exist in version 1, so there is nothing to migrate. It only matters once you want custom persistence.

3. Configuration

default-adapter becomes prioritized-adapters

Version 1 named the adapter of a module. Version 2 names an ORDER of adapters, because several BPMS may serve one application. This is what makes BPMS migration a matter of configuration. The key exists at the same three levels as before:

# before
vanillabp:
  default-adapter: camunda7
  workflow-modules:
    ride:
      default-adapter: camunda7
      workflows:
        RideProcess:
          default-adapter: camunda7

# after
vanillabp:
  prioritized-adapters:
    - camunda7
  workflow-modules:
    ride:
      prioritized-adapters:
        - camunda7
      workflows:
        RideProcess:
          prioritized-adapters:
            - camunda7

The first entry starts new workflows. The others are asked about workflows started earlier. The most specific value that is not empty wins as a whole. A workflow's list replaces the module's list, it does not merge with it. No other setting works this way, so read again any list you wrote in version 1. The general rule is on the levels a setting is written at.

Workflow modules are declared by a descriptor file

Version 1 had three ways to name a workflow module: spring.application.name (for applications with a single module), the name of the module's configuration file, or a static bean method. That method declared the id together with the module's properties class.

// version 1
@Bean
public static WorkflowModuleProperties ModuleProperties() {

    return new WorkflowModuleProperties(
            LoanApprovalProperties.class,
            LoanApprovalProperties.WORKFLOW_MODULE_ID);

}

Version 2 wants the id written explicitly, in one place. Then a module travels with its own artifact, and both platforms find it the same way. Add a text file META-INF/workflow-module whose content is the module id.

src/main/resources/META-INF/workflow-module      # content: loan-approval

Write one file per workflow module. In a multi-module project it goes into the artifact which implements that use case. The details per platform are on Spring Boot and Quarkus. An application without any descriptor is treated as one single global module. So the file is what makes a use case a module of its own.

Then delete the bean method. WorkflowModuleProperties no longer exists, so a project which keeps the method does not compile. The properties class it named stays as it is, because the binding of a module's own configuration has not changed.

Keep the ids you had. They name BPMS tenants and resource directories. If you change one, the running workflows of that module are no longer connected to their configuration.

A workflow module's own files are still defaults

This is unchanged from version 1. A file named after the workflow module (loan-approval.yaml, loan-approval-prod.properties) holds defaults, and everything the application configures wins over it. Version 1 did this with a PropertySourcesPlaceholderConfigurer, which looked in the environment first. Version 2 puts the module's property sources below everything else, and Quarkus does the same with ordinals. Both platforms now behave the same way (the order).

What version 2 did add: these values are real configuration properties now. Version 1 kept them out of the environment. ${...} and @Value could see them, but Environment#getProperty and @ConfigurationProperties could not. Now they can, so you can bind a workflow module to a @ConfigurationProperties class of its own.

Adapter settings move to vanillabp.adapters.<id>.*

In version 1 an adapter's settings lived per workflow module (vanillabp.workflow-modules.<m>.adapters.<adapter>.*). Its connection was configured by the BPMS' own Spring integration: camunda.bpm.* for Camunda 7, and the Camunda Spring SDK's camunda.client.*/zeebe.client.* for Camunda 8. In version 2 an adapter id names one BPMS instance. Everything that instance needs lives in its own section. VanillaBP owns that section, and no BPMS-specific Spring configuration is involved:

# before (Camunda 8, connection via the Camunda Spring SDK)
camunda:
  client:
    mode: self-managed
    zeebe:
      grpc-address: http://localhost:26500
vanillabp:
  default-adapter: camunda8
  workflow-modules:
    ride:
      adapters:
        camunda8:
          task-timeout: PT5M

# after
vanillabp:
  prioritized-adapters:
    - camunda8
  adapters:
    camunda8:                 # the id; 'type' may be omitted because the id IS the type
      mode: self-managed
      rest-address: http://localhost:8080
      job-timeout: PT5M       # adapter-wide default, overridable per module/workflow/task

An id which is not an adapter type needs vanillabp.adapters.<id>.type. This is how you tell two instances of the same BPMS apart:

vanillabp:
  prioritized-adapters:
    - saas          # new workflows start here
    - onprem        # existing workflows may still live here
  adapters:
    saas:
      type: camunda8
      mode: saas
      cluster-id: ...
    onprem:
      type: camunda8
      rest-address: http://camunda.internal:8080

The exact keys belong to each adapter. See the adapter wikis: Camunda 7, Camunda 8.

Key by key

Version 1 Version 2 Note
vanillabp.default-adapter vanillabp.prioritized-adapters a list now, at all three levels
vanillabp.workflow-modules.<m>.default-adapter vanillabp.workflow-modules.<m>.prioritized-adapters
…workflows.<w>.default-adapter …workflows.<w>.prioritized-adapters
vanillabp.workflow-modules.<m>.adapters.<id>.resources-location unchanged, but optional derived as classpath*:<module>/processes/<adapter> (see Workflow modules)
camunda.bpm.* (Camunda 7 engine) vanillabp.adapters.<id>.* the adapter runs the plain engine itself, so the Camunda Spring Boot starter is gone. The keys are database-schema-update, history-time-to-live, data-source-name and table-prefix. The last one needs a schema you create yourself, because Camunda does not create tables with a prefix
camunda.client.* / zeebe.client.* (Camunda 8) vanillabp.adapters.<id>.* mode, rest-address, grpc-address, prefer-rest-over-grpc, tenant-id, cluster-id, region, client-id, client-secret
…adapters.camunda8.task-timeout job-timeout you can set it per adapter, workflow module, workflow and task
…adapters.camunda7.use-bpmn-async-definitions — you cannot configure this any more. The adapter sets asyncBefore/asyncAfter on service-like tasks when it parses the model, so every task runs in its own transaction (Camunda 7 configuration)
…adapters.<bpms>.tenant-id vanillabp.adapters.<id>.tenant-id the tenant now belongs to the BPMS INSTANCE, not to the workflow module. The key only NAMES the tenant. Without it the workflow module id is used, exactly as in version 1
…adapters.<bpms>.use-tenants: false vanillabp.adapters.<id>.name-clash-avoidance: none one concept replaces it, see below
…adapters.camunda8.task-id-as-hex-string — not a property to move, but data to migrate. The ids your application stored while this was on have to be converted. The adapter tells you where it finds one (the step, why)
vanillabp.allow-connectors vanillabp.adapters.<id>.allow-connectors the key moved under the ADAPTER. Connectors are a Camunda 8 concept, and no other BPMS has anything to do with the marker in the model. The three levels are the ones you had: adapter, workflow module and workflow. The default is still false. What changed: a more specific level may now switch it OFF as well as on. Version 1's booleans could only switch it on. A user task built from an element template now stays wired, so it needs a @WorkflowTask method. Version 1 skipped it. And every boot of a workflow module which allows connectors writes a warning. The warning names the elements the module handed over and what they cost, and no key turns it off (Camunda 8 configuration)
…adapters.camunda8.retry-backoff vanillabp.adapters.<id>.retry-backoff the same as before: the backoff of a failed job, which you can set per workflow module, workflow and task. One difference, see Behaviors that differ: it has a default now. The adapter reads the retryBackoff task header of your model as before (Camunda 8 configuration)
vanillabp.resilience.* removed nothing ever read it. Retry settings come back per adapter together with their first user, which is the row above

Keeping workflow modules apart: choose a mode

Version 1 kept a workflow module apart with a BPMS tenant, and you could switch that off per module (use-tenants). Version 2 makes it one explicit setting with three values:

vanillabp:
  adapters:
    camunda7:
      name-clash-avoidance: by-adapter   # tenant per workflow module (version 1's behavior)
      # name-clash-avoidance: use-prefix # no tenant, VanillaBP prefixes the identifiers
      # name-clash-avoidance: none       # nothing is scoped (the old 'use-tenants: false')

If you configure nothing, by-adapter applies. This is version 1's behavior on both Camunda adapters. So an application upgraded from version 1 keeps its tenants without any configuration.

Your adapter id, and why it is worth a thought now. Version 1 had one adapter, and its id was its type. Version 2 keeps that: a section named camunda7 or camunda8 needs no type. Keep it while you run only one BPMS. Once a second one joins, both need names of their own. That may be another cluster, another cluster version, or SaaS next to on-premises. And you cannot rename the old one to a clearer name later. VanillaBP stores the adapter id to know what belongs to which BPMS. So a rename creates a new adapter, next to workflows which nothing serves any more. What that costs in detail, and how to change a BPMS instead, is on naming adapter ids, and never renaming them. If you choose none instead, the adapter logs a WARN per workflow module which names the alternatives. accept-unscoped-identifiers: true records that you made this decision.

Two settings which used to fit together now contradict each other, and the boot tells you. A tenant-id fails the boot if no level uses by-adapter, because the deployment would ignore the configured name. Where by-adapter does apply, Camunda 8 also asks the cluster before it deploys whether it can use the tenant at all.

Read how name clashes are avoided before you switch to use-prefix. It changes the identifiers the BPMS sees, so it is a migration, not a property change. Two notes for upgraders:

  • Camunda 7 users: nothing to do. Version 1 deployed every workflow module into a tenant named after the module, and by-adapter is the default. So the identifiers the engine answers to stay the same, and the workflows you started in version 1 are found through their tenants. If you ran with use-tenants: false, set none. If you forget, the deployment still succeeds. So the adapter counts the workflows your configuration will not reach, and says so at every start.
  • Camunda 8 users: nothing to do either, as long as your cluster has multi-tenancy enabled and knows the tenants. In version 1 use-tenants was on, and the tenant defaulted to the workflow module id. This is exactly what by-adapter deploys. Two cases need one line. A cluster WITHOUT multi-tenancy rejects a tenant id. And an application which ran with use-tenants: false keeps that scoping with vanillabp.adapters.<id>.name-clash-avoidance: none. Where the cluster refuses, the startup message names both ways out.

What you may now delete

An application with one adapter dependency and one workflow module needs no vanillabp.* property at all. The classpath is the configuration. The adapter section, the module section and resources-location are all derived. Only what the BPMS itself needs remains, for example a cluster address. Everything you configured explicitly keeps working.

Two Camunda 8 tables, if you come from 1.6.3 or earlier. After the upgrade nothing reads CAMUNDA8_DEPLOYMENTS and CAMUNDA8_RESOURCES. They are the one thing worth dropping by hand (the step).

A new section for remote BPMS: the outbox

An adapter whose BPMS is remote cannot take part in your local transaction. So it starts workflows in two phases, through a transaction outbox. The outbox is a table of calls which VanillaBP writes in your transaction and carries out after the commit. Coming from version 1, this concerns the Camunda 8 adapter. The outbox works out of the box, and you can tune it with vanillabp.outbox.*. Read what it guarantees before you go to production. Watching for blocked entries is a job for operations.

Four tables come with it, in the database where your workflow aggregates live:

  • the outbox store,
  • the payloads of the calls which carry one,
  • the log of processed task deliveries, which keeps a redelivered task from running your handler twice,
  • the claim which says which node removes the old rows tonight.

VanillaBP creates all four at startup. So an application which lets it do that has nothing to prepare. Some applications have a schema which is reviewed and applied by their deployment pipeline. They set vanillabp.outbox.create-schema: false, one switch for all four tables, and apply the statements themselves. The statements ship as the artifact io.vanillabp:vanillabp-schema. It holds a Liquibase changelog and generated Flyway scripts per database, and it pulls in no runtime. With the switch off, VanillaBP checks at startup that the tables exist. If one is missing, it ends the boot and names that table, instead of failing at the first workflow start. The details per platform are on Spring Boot and Quarkus.

If you forget the outbox, you do not find out in production. Say the first adapter in the priority list needs a two-phase commit, and VanillaBP finds no outbox store. Then the application does not boot, and the message names what to add. At the same moment VanillaBP checks the transaction the outbox entry would be written in. Camunda 7 is included, which is new. The outbox itself is new as well, because version 1 had none. On Camunda 8 a listener completed the job after your transaction had committed, so nothing about that call was written down. Version 1 left no table of two-phase calls behind, so there is nothing to drain or carry over.

4. Code changes

Removed ProcessService overloads

The overloads which take a message object are gone. They used the simple name of the object's class as the message name. Message content never travels to the BPMS. So an object which only carries a name did not deserve an API of its own:

// before
processService.correlateMessage(ride, rideConfirmation);
processService.correlateMessage(ride, rideConfirmation, correlationId);
processService.startWorkflowByMessage(ride, rideRequested);

// after — name the message explicitly (formerly message.getClass().getSimpleName())
processService.correlateMessage(ride, "RideConfirmation");
processService.correlateMessage(ride, "RideConfirmation", correlationId);
processService.startWorkflowByMessage(ride, "RideRequested");

Put the message's data into the aggregate before you correlate. As before, the aggregate is the single source of truth.

@BpmnProcess.primary() is gone

The place where a process is declared tells whether it is the primary one. bpmnProcess is primary, secondaryBpmnProcesses are not.

// before
@WorkflowService(workflowAggregateClass = Ride.class,
    bpmnProcess = @BpmnProcess(bpmnProcessId = "RideProcess", primary = true),
    secondaryBpmnProcesses = @BpmnProcess(bpmnProcessId = "RideCancellation", primary = false))
// after
@WorkflowService(workflowAggregateClass = Ride.class,
    bpmnProcess = @BpmnProcess(bpmnProcessId = "RideProcess"),
    secondaryBpmnProcesses = @BpmnProcess(bpmnProcessId = "RideCancellation"))

Note that secondaryBpmnProcesses is honored now. The platform integration of version 1 ignored it. Every declared BPMN process is wired for task processing. Secondary entries must name an explicit bpmnProcessId.

Some version 1 applications spread the processes of one workflow aggregate over several classes, each with its own bpmnProcess. These have to bring them together. One class declares the process which startWorkflow starts. The others go into its secondaryBpmnProcesses. Handlers may still live in separate classes, as long as each of them declares that same bpmnProcess. Version 2 reports the problem while starting (Quarkus: while building). It does not pick one of the processes by the order in which the classes were found.

A BPMN process also belongs to exactly one workflow aggregate. Two classes of different aggregates may have named the same bpmnProcess. Version 1 logged a warning in that case and kept the class it found first. Version 2 stops the start (Quarkus: the build). It does the same if one of the classes lists the process in secondaryBpmnProcesses. The message names both classes and both aggregates. Then decide what the process is. If it is a workflow of its own, only classes of its aggregate declare it. A call activity may still call it. In that case the mappings of the call activity in your BPMS set up the data the called workflow gets. Still, two workflows are usually better started and synchronised by messages. If the process is a step of another workflow, only the class of that workflow lists it in secondaryBpmnProcesses. See Workflow tasks.

Remove @Transactional from workflow services

Version 1 asked you to put @Transactional(noRollbackFor = TaskException.class) on workflow services. VanillaBP 2 manages the transaction around a @WorkflowTask method itself: it loads the aggregate, calls the method and saves the aggregate. This includes the old contract. A TaskException commits the changes to the aggregate and completes the task with a BPMN error.

// before
@Service
@WorkflowService(workflowAggregateClass = Ride.class, ...)
@Transactional(noRollbackFor = TaskException.class)
public class RideService { ... }

// after — no transaction annotation of your own
@Service
@WorkflowService(workflowAggregateClass = Ride.class, ...)
public class RideService { ... }

A transaction boundary of your own around a handler may change when a rollback happens. That is why it has to go. Code which CALLS ProcessService still needs a transaction. startWorkflow, completeTask, cancelTask, completeUserTask, cancelUserTask and correlateMessage need an active one. The read-only viewer methods do not.

Keeping the version 1 annotation is valid too. The startup check accepts a rollback rule which excludes TaskException. So the version 1 line @Transactional(noRollbackFor = TaskException.class) boots and behaves as before. The boot fails for an annotation which joins VanillaBP's transaction WITHOUT such a rule. With such an annotation, a TaskException would throw away the aggregate changes instead of committing them. The check also reports an annotation inherited from a superclass or an interface, and one hidden inside a custom annotation of yours. If the annotation sits on a bean the handler CALLS, no startup check can see it. Then the task fails at runtime, with a message which names the workflow, the task and the same two fixes. See Workflow tasks.

First check whether the class is also your business service. Small applications often keep everything in one class. The @WorkflowTask methods sit next to the business methods your API calls. If you delete the annotation there, the business methods lose their transaction too, and they still need one. VanillaBP wraps only @WorkflowTask methods, and every call into ProcessService needs an active transaction. So a startWorkflow from an endpoint fails once the annotation is gone. Nothing reports this. The application starts, the workflows the BPMS drives keep working, and only the paths which start in your API break. You have two options. Split the class, and move the @WorkflowTask methods into a class of their own, as the blueprints do. Or keep @Transactional(noRollbackFor = TaskException.class) on the combined class, which the startup check accepts. The one wrong option is to delete the annotation and leave the class combined.

A javax.transaction.Transactional carried over from Spring Boot 2 is honored by neither Spring Framework 7 nor Quarkus 3. It does not break the contract. It simply does nothing. VanillaBP warns about it at startup, so you do not rely on a transaction boundary which does not exist.

Hand-written ProcessService implementations

ProcessService got new methods, and they are default methods so this does not break again. A VanillaBP adapter implements getProcessDefinitions, getBpmnXml, getWorkflowHistory and startWorkflowByMessage(A, String). So test doubles keep compiling, and only getWorkflowModuleId() stays abstract.

Custom aggregate persistence is not part of the upgrade, because version 1 had no SPI for it (above). If you write an AggregatePersistenceAware now, note these points. All its methods are default methods with guiding messages. loadById(Object) is the one to implement, because VanillaBP loads aggregates itself (for task processing and aggregate sync). Some IDs do not convert from and to String without loss. For such an ID, either name its type in getAggregateIdType(), or return null there to own the serialized form. Remote BPMS also need getAggregateIdName(). Your own implementation always wins over the built-in one.

5. Behaviors that differ

Nothing to change in your code, but worth knowing before you go live.

  • A workflow whose aggregate shares everything does not start. This is the one item here which stops your first boot. Version 1 had no sync model, and the embedded Camunda 7 engine read the aggregate live. So an application which never annotated anything now hands every attribute to the BPMS. You have two options. Say what the models need, with @NoSyncWithBPMS on the class and @SyncWithBPMS on the attributes an expression reads. Or allow it per workflow with vanillabp.workflow-modules.<module>.workflows.<process>.allow-full-sync-with-bpms: true. This permission is not inherited, so you write it at every workflow it applies to (see Workflow aggregates).
  • A task somebody else serves can say so. Version 1 ended the start for every task without a @WorkflowTask method, user tasks included. Version 2 does the same. What is new is a way out for a task you leave to a task list or to another worker on purpose: vanillabp.workflow-modules.<module>.workflows.<process>.tasks.<element-id>.implemented-externally: true (see Workflow tasks).
  • A process somebody else serves can say so. Version 1 ended the start for every BPMN process which no @WorkflowService declared, even for a process which only sits in your file. Version 2 does the same. What is new is the way out: vanillabp.workflow-modules.<module>.workflows.<process>.implemented-externally: true. Such a process is deployed with its file, and VanillaBP leaves it alone (see Workflow tasks).
  • An application can learn that a workflow ended. Version 1 offered nothing for this, so applications modelled a service task in front of every end event. @WorkflowEnded replaces that (see Starting workflows). The old service task keeps working. So this is something to simplify when you have time, not a migration step. On Camunda 8, wait until the workflows you upgraded have ended before you delete the old service task. A workflow started under version 1 never triggers the new notification (the step). Camunda 7 needs no such wait.
  • BPMN signals can be sent. Version 1 had no API for this, so you could use a signal event only between two elements of the same model. ProcessService.sendSignal(name) now broadcasts one (see Message correlation). A signal is a broadcast, so it takes no workflow aggregate.
  • An application can tell the BPMS that the aggregate changed. Version 1 pushed the aggregate only at its own sync points. So a conditional event had nothing to react to, and a gateway evaluated whatever the last completed task had written. ProcessService.aggregateChanged(aggregate) pushes on demand, and aggregateChanged(aggregate, taskId) pushes into the scope of one task instance (see Workflow aggregates). On Camunda 7 this makes conditional events usable. On Camunda 8 it finds the workflow by searching the cluster. This is one of the reasons why that adapter needs a cluster it can search.
  • Workflows the BPMS starts on its own now get a workflow aggregate. In version 1 you could not use a timer, signal or conditional start event. The workflow had no aggregate, so nothing could be routed to it. In VanillaBP 2 a @WorkflowStartedByBpms method of your workflow service builds the aggregate and gives the workflow its id (see Starting workflows). Nothing changes for the workflows you start yourself.
  • Remote BPMS start workflows in two phases. startWorkflow no longer talks to the BPMS inside your transaction. An outbox entry is written in your transaction, and the instance is created after the commit. So a transaction which is rolled back can no longer leave a workflow behind. In exchange the start shows up in the BPMS a moment later, and it is at-least-once. A duplicate start is unlikely, because VanillaBP checks before it sends a call again. But it is not impossible. Camunda 7 now works this way as well. It delivers tasks inside the engine's transaction, but every operation which moves a workflow forward runs after your commit. So such an operation can be repeated when it loses a concurrency conflict. An application on Camunda 7 therefore needs a transaction outbox, which version 1 did not ask for.
  • A failed job waits ten seconds before the cluster hands it out again. On Camunda 8, version 1 sent a backoff only where one was configured. So a handler which failed on something that needs a moment used up its three retries as fast as the cluster could deliver the job again. vanillabp.adapters.<id>.retry-backoff now defaults to PT10S. You can still set it per workflow module, workflow and task, and a value your application configured keeps its meaning. The second way version 1 offered still works too. A retryBackoff task header in the BPMN model, per element, is read as before. It no longer needs the retries attribute which version 1 also demanded. If a task is both configured AND modelled, the configured value of that one task wins, and one log line says so. Every level above the task loses to the model (Camunda 8 configuration).
  • A repeated delivery of a task no longer runs your handler again. Remote BPMS deliver tasks at least once. Version 1 passed this straight through. So a redelivery called the @WorkflowTask method a second time, and every handler had to protect itself. VanillaBP 2 records what it processed and answers a repeated delivery from that record (see Workflow tasks). The guards in your handlers stay correct. They still cover what a record cannot: two deliveries running at the same time, and everything a handler does outside its transaction.
  • One error contract for @WorkflowTask methods on all BPMS. A normal return completes the task. A TaskException becomes a BPMN error, and the aggregate changes are committed. Any other exception rolls the transaction back and leaves retrying to the BPMS. See Workflow tasks.
  • Operations on existing workflows find their BPMS by asking. completeTask, cancelTask, the user task operations and correlateMessage ask the prioritized adapters, and remember the answer. The task operations usually skip that question. When the task was delivered, VanillaBP wrote a record which names the BPMS that handed it out, see The record also says which BPMS holds the task. A task or workflow which no BPMS knows raises a TaskNotFoundException / WorkflowNotFoundException with a guiding message. If it is already completed, the operation does nothing and logs a warning. While a BPMS cannot be reached, the operation fails. It does not fall back to another BPMS, because quietly using the wrong BPMS would be worse.
  • Configuration defects fail at startup, not at runtime. The messages name the property keys to add. An application whose adapter section is missing still boots and tells you what to write. A section which really contradicts itself fails the boot.
  • The start names the expressions in your models. Version 1 read none of them. Version 2 warns per BPMN process about an expression which walks a path (${order.shipping.express}) or calls something (${count(order.items) > 3}), because a rename in Java breaks it. An expression which only computes (${not bigItem}) gets a notice. An expression which names a single variable is not mentioned. Workflow aggregates has always recommended that kind. Nothing stops the start, and no model has to change. If a process is meant to stay as it is, write vanillabp.workflow-modules.<module>.workflows.<process>.accept-expressions-in-the-model: true. The same key at the workflow module or at vanillabp covers everything below it (see What the start says about your expressions).
  • Process definition ids of the viewer API have a namespace, <adapter id>#<BPMS specific id>, and are opaque. Pass them back unchanged. Never parse them or build them yourself. See Viewing workflows.
  • version does what version 1 only documented. The attribute exists on @WorkflowTask since version 1, and now also on @WorkflowStartedByBpms and @WorkflowEnded. Version 1 never evaluated it, so every method served every version. VanillaBP 2 matches it against the version of the deployed process definition which the BPMS reports. A boundary may also name a version tag of the model (see Workflow tasks). This has consequences for an application which already uses the attribute. Methods whose ranges were meant to be disjoint really are disjoint now. So a version which none of them serves fails the delivery, instead of running the first method. There is one exception, a task of the model the boot deploys. If none of its methods covers the version the BPMS gives that model, the boot ends (see Workflow tasks). A BPMS which reports NO version reaches only methods without the attribute. So a task whose methods all name versions fails the delivery there. And two methods wired to one task with OVERLAPPING ranges fail the boot, where version 1 accepted them. Applications without the attribute see no change.
  • @SyncWithBPMS/@NoSyncWithBPMS are real now. Version 1 documented them, but did not ship them. Without any annotation EVERY adapter shares everything, Camunda 7 included. Your models keep reading what they read in version 1, but now through process variables instead of a live read of the aggregate. As soon as you annotate one attribute, you are in charge. Read Workflow aggregates before you annotate, because annotating a single attribute also decides what happens to all the others.
  • On Camunda 7 the live read of the aggregate is now a fallback, and it will be removed. Version 1 answered every BPMN expression by reading the aggregate. Version 2 writes the shared values as process variables and lets the engine resolve them. Workflows which were already running when you upgraded have no such variables. So where the engine has none, the adapter still reads the aggregate, and logs once what it did. No version is named for the removal, so ask the VanillaBP team if you need a date. What you can watch is the adapter's own report. Once an hour it says how many workflows the fallback has served since the application started. That number falls by itself. A workflow stops needing the fallback as soon as it reaches the next point where the adapter writes the shared values. The sharing rules do not cover two cases at all, and both were legal in version 1: an attribute which can only be read as a FIELD, and an isX() method which returns something other than boolean. Give those a getter (getX(), or isX() returning boolean) and share it. While your application starts, the Camunda 7 adapter lists the expressions whose path stops before it reaches a value the engine holds. For each one it names the element, the segment where the path stops, and what this engine will do with the resulting null. That list is your migration backlog, but it is not complete. The check reads declared types. So it says nothing where a path runs through a Map, an interface or a collection without an element type. It also judges no method call. So it leaves out ${order.getTotal()} and ${order.status.name()}, although both stop working. Those two raise an incident which names the expression, so the engine tells you about them. The check exists for the silent cases. The main one is a conditional event. It answers a condition it cannot evaluate with false, and then waits forever.
  • On Camunda 8 your running workflows already have their variables, and two things can spoil that. Version 1 handed the whole workflow aggregate to every command it sent. So an instance started under version 1 has a process variable for everything its JSON serialization could read, including the ID attribute. That ID variable is what version 2 looks for when it probes the cluster. Two narrow cases break this, and both are in your code, not in your configuration. First, the aggregate's ID attribute may be renamed for JSON (@JsonProperty on the ID). Then version 1 wrote the variable under the JSON name, while version 2 looks for the name your persistence reports. Every probe for an instance from before the upgrade finds nothing. Second, version 1 serialized FIELDS, and version 2 does not. Such a variable keeps the last value version 1 wrote, forever, because version 2 only refreshes what it reads from a getter. The value is stale, not missing. So give such an attribute a getter and share it.
  • A message starts only the process of its own ProcessService. Version 1 handed the message of startWorkflowByMessage to the BPMS, so every process with a start event for that name started. Version 2 refuses a message which the model of the service's own process does not start. It throws an IllegalArgumentException which names the messages that do. If your code started another process this way, call the ProcessService of that process instead (see Message correlation). An adapter which cannot name the messages of its model is not checked, and the start says so.
  • Camunda 8 cannot cancel a user task managed by Camunda with a BPMN error (cancelUserTask), and an error with guidance tells you so. Clusters up to 8.9 have no command for it. The listeners of 8.10 make it possible, but the adapter does not use them for this yet, on any release line. So you take such a task away in your model (the step).

A user task your cockpit showed as active while a check answered 404

This is about Camunda 8 only, and only if you have seen it. The Business Cockpit shows a user task as active, but a check about that same task answers 404. Under version 1 this had one of two causes. Version 2 has neither.

The first cause is the storage the question went to. Version 1 asked whether a user task still exists with a UserTaskGet. That reads the cluster's searchable storage. An exporter writes that storage, and the exporter runs behind the engine. Your application, and with it the cockpit's record, learns about a task earlier. It learns from the creating task listener, a job which the engine hands out before the task even exists. So for a while the cockpit has the task and the storage does not. We measured this on 2026-09-28 against camunda/camunda:8.8.39, camunda/camunda:8.9.21 and camunda/camunda:8.10.0-rc1, counting from the moment the listener job arrived. The storage answered 404 for 1667 ms on 8.8, 649 ms on 8.9 and 219 ms on 8.10. Under load the gap is as long as your exporter's backlog. Version 2 asks the engine instead, with an UpdateUserTask which changes nothing. In the same runs it answered 204 after 12 ms, 21 ms and 13 ms, in the same order.

The second cause is the shape of the task, and there the 404 never goes away. A user task served by a job worker has no user task record in the cluster at all. Version 1 modelled user tasks this way up to its release 1.6.3. Such a task only has a job, and the id your application holds for it is that job's key. So every question asked as if it were a user task misses. In the same runs UserTaskGet, the empty UpdateUserTask and the user task search answered only "not found" for a full minute. At the same time the job command on the same key was accepted, and the engine said it still holds the instance. Version 2 does not serve that shape, and names it while it deploys. This is the first thing to do in Migrate in six steps: change those models, and finish what is open on them before you upgrade.

What the upgrade costs under load

Your business code pays nothing here. The outbox does. Version 1 wrote no outbox entry and kept no record of task deliveries. So everything below is work which version 2 does around code you do not touch. It is counted in rows, transactions and threads. No milliseconds appear, because a time measured on one machine is read as a promise on another.

A workflow start costs one outbox entry. It is written in your transaction, which still commits once. The call to the BPMS then runs after that commit, in a transaction of its own and on a thread which is not yours. On Camunda 8 that second transaction is new. On Camunda 7 it is not. Version 1 wrote a job of its own engine inside your transaction, and the job executor carried it out afterwards. So the count is the same. What changed is the table the row goes into and the thread which picks it up.

On Camunda 7 an application pays when it answers a message, and also when it completes or cancels a task. Version 1 called the embedded engine while your transaction was open, so your commit covered that work. Version 2 writes an entry for it, which is one row and one transaction more than before. On Camunda 8 those operations left the committing thread under version 1. Now they are entries, like the start.

Every task delivery costs one record, written in the transaction of your handler. It costs no transaction of its own. This record answers a repeated delivery, instead of running your @WorkflowTask method a second time.

Your own handler is not in those numbers, but it did move. Under version 1 it ran inside the engine's job transaction on Camunda 7, and inside the transaction your @Transactional opened on Camunda 8. Now it runs inside a transaction which VanillaBP opens around it. How long it holds that transaction is still up to you.

The threads are what you need to size. On Camunda 7, version 1 ran its engine calls on the engine's job executor. Camunda's Spring Boot starter starts it with three threads and grows it to ten. On Camunda 8, version 1 used the thread which committed, so it had as many threads as your requests. Version 2 dispatches on four threads (vanillabp.outbox.dispatch-threads). The workflow aggregate decides which of them takes an entry. So the operations of one workflow keep their order, while different workflows run at the same time. On Camunda 7 those four threads are a second pool, not a replacement. The engine's job executor is still there and still delivers your tasks. The dispatch threads carry the calls VanillaBP makes into the engine. Some applications raised camunda.bpm.job-execution.max-pool-size because the job executor was their bottleneck. They now have two numbers to raise. Both pools use connections of the database where your workflow aggregates live. What VanillaBP is sized for has the rest of the picture.

6. Business Cockpit

An application which reported to the VanillaBP Business Cockpit keeps its Java code. @UserTaskDetailsProvider, @WorkflowDetailsProvider, @DetailsEvent, the prefilled details objects and BusinessCockpitService are unchanged. What changes is the dependency, where the keys live, and the moment a report is sent.

The artifacts

Version 1 shipped the cockpit's integration as adapters in the cockpit's own repository: io.vanillabp.businesscockpit:camunda7-spring-boot-adapter and camunda8-spring-boot-adapter, on top of adapters-commons. They ran on Spring Boot only, and only next to VanillaBP 1's spring-boot-support. Version 2 makes the cockpit an adapter of its own, which you add next to the adapter of your BPMS. The VanillaBP core has its own word for such a thing, and the glossary puts both side by side. There is one repository per BPMS and one variant per platform. All of them use the group io.vanillabp.businesscockpit:

BPMS Spring Boot artifact Quarkus extension Documentation
Camunda 7 businesscockpit-camunda7-adapter-spring-boot businesscockpit-camunda7-adapter-quarkus Wiki
Camunda 8 businesscockpit-camunda8-adapter-spring-boot businesscockpit-camunda8-adapter-quarkus Wiki
Process-Engine-API businesscockpit-process-engine-api-adapter-spring-boot businesscockpit-process-engine-api-adapter-quarkus Wiki

The Camunda 8 artifact is released once per Camunda 8 minor version. So its version names the line it was built against, 0.x.y-8.9. The Process-Engine-API variant has no predecessor in version 1. Its wiki says what that engine can tell the cockpit today, and what it cannot. On Quarkus the platform-neutral part is a Quarkus extension of its own. You add it next to the BPMS extension, as the wikis show.

The configuration stays where it is

The Business Cockpit reads the same keys as in version 1, in the same places: vanillabp.cockpit.* for the whole application, vanillabp.workflow-modules.<id>.cockpit.* for a workflow module, and vanillabp.workflow-modules.<id>.workflows.<process>.cockpit.* with its user-tasks.<task>.cockpit.* for the two levels below. An application upgrades by changing a dependency. How the cockpit changed inside is nothing its configuration needs to know about. The keys which are gone, and the reason for each, are on the Configuration page of the wiki for your BPMS (Camunda 7, Camunda 8, Process-Engine-API).

VanillaBP itself declares nothing below vanillabp.cockpit. The cockpit binds that part of the tree itself. A BPMS adapter uses the same overlay for its own keys. So a key which belongs neither to VanillaBP nor to the cockpit still ends a Quarkus startup, instead of being ignored.

A key which is gone does nothing on Spring Boot if you leave it in place. On Quarkus it ends the build with a message which names it, because Quarkus refuses a key below vanillabp which nothing reads. On both platforms, watch for a workflow module whose section is incomplete. A module which sets some keys and leaves others out ends the boot. The message names every gap in the application. A module which sets nothing at all is read as a module which does not want to report, and the application starts. One INFO line per such module names the key which would let it report. The cockpit server never hears about that module.

Code changes

The details provider methods and BusinessCockpitService keep their API. One attribute is refused now. A @UserTaskDetailsProvider which names a version ends the boot, with a message which names the method. Version 1 documented that attribute but never read it. Also, the events the adapter reacts to do not say which version of a process they came from. So nothing could choose between two methods anyway. Match by id or taskDefinition instead.

A report is sent after your commit

A report is now an outbox entry. It is written in the transaction which caused it, and sent once that transaction has committed. So your details provider runs at dispatch time, not inside the engine's own work. If it changes an aggregate, the change is saved in that later transaction, not in the one the task ran in. In return, a report survives a cockpit server which is down. And a burst of changes to one task collapses into a single report with the current state.

What differs per BPMS

On Camunda 8 the cockpit's own three task listeners of a user task are byte-identical to the version 1 ones. But the start of a workflow is now reported from an execution listener on every start event, instead of one listener on the process. This lets the report name the business case the workflow is about. So the model your application deploys after the upgrade differs from the one the cluster holds, and the first deployment creates a new process version. Workflows which are already running stay on the version they started on, and keep reporting their user tasks.

On Camunda 7 the engine has to write history, which version 1 never asked of it. A workflow's lifecycle is now read from the engine's history, not from the eventing of the Camunda Spring Boot starter. Every history level from activity upwards has what is read, including Camunda's default audit. So an engine whose level you never lowered needs no change. An engine whose history is switched off ends the boot with a message.

The full list per BPMS, including the settings which are gone, is on the Migrating from version 1 page of the Camunda 7 and the Camunda 8 wiki.

7. Migrate in six steps

  1. Camunda 8 with user tasks: check the models first, and finish what is open. Up to 1.6.3, VanillaBP modelled a user task with a formKey in its zeebe:formDefinition. Version 2 does not serve such a user task. A BPMN process which one of your @WorkflowService classes claims, and which has such an element, does not deploy at all. So your application will not start until you have changed the model. And a task open on such an element can no longer be completed once you have upgraded (the step).
  2. Lift the runtime: Java 21, Spring Boot 4 (or Quarkus 3.37). Fix the Spring imports which moved when Spring Boot was split into modules.
  3. Swap the dependencies: the adapter artifact was renamed, and on Quarkus you add both extensions. Then build. The compiler points at the three removed overloads and at @BpmnProcess.primary. Persistence needs nothing here. What version 1 could do, JPA and MongoDB through Spring Data, version 2 does out of the box.
  4. Declare your workflow modules with one META-INF/workflow-module descriptor each (same ids as before). Delete the WorkflowModuleProperties bean method where one declared the module. Then move the configuration: default-adapter → prioritized-adapters, adapter settings into vanillabp.adapters.<id>.*, and delete what the conventions now derive. Start the application and follow the startup messages. They name every missing key.
  5. Delete your transaction annotations on workflow services. Check that nothing else wraps a @WorkflowTask method in a transaction of its own. Where a class is the workflow service AND your business service, either the annotation stays or you split the class.
  6. Verify against your BPMS: start one workflow of every process, complete an asynchronous task, and correlate a message. On a remote BPMS also look into the outbox store once (entries should reach DONE), and decide on your retention.

Moving between BPMS, not between versions, is a different topic: BPMS migration.

Clone this wiki locally