Skip to content

Starting workflows

Stephan Pelikan edited this page Oct 11, 2026 · 21 revisions

A workflow is started in one of two ways. Either your application starts it, or it reaches your application already running. In both cases your code builds a workflow aggregate, and that aggregate names the workflow.

  1. Your application starts it
    The common case: you build the workflow aggregate and hand it over.
  2. A workflow nobody started through VanillaBP
    A timer fires, a signal arrives, or somebody uses the BPMS directly. Your code builds the workflow aggregate.
  3. How VanillaBP tells the two apart
    The name the BPMS holds, where each BPMS keeps it, and what happens when it holds a name VanillaBP did not give.
  4. When a workflow ends
    How your application learns that a workflow finished, why a called process does not count, and what each BPMS can report about it.

Your application starts it

This is the common case. Your code builds the workflow aggregate, fills it with the data the process needs, and hands it to the ProcessService.

final var ride = new Ride();
ride.setPickup(request.getPickup());
bpms.startWorkflow(ride);

From then on, the aggregate carries the ID which identifies the workflow. startWorkflowByMessage does the same for a process whose start event waits for a message. Everything about this is your decision, including what the aggregate's ID is. VanillaBP passes that ID to the BPMS (business process management system), and the BPMS keeps it. How VanillaBP tells the two apart says where.

On a remote BPMS the start finishes after your commit

If the BPMS runs outside your application, startWorkflow cannot ask it inside your transaction. Otherwise the BPMS could accept the start, then the transaction could roll back, and you would have a workflow without an aggregate. So your transaction writes the aggregate together with a note that this workflow has to be created. The call returns, and the BPMS is asked once that transaction committed. The note lives in the transaction outbox, and a dispatcher works through it.

This second half can fail after your code is done with it, and your application is not told. Usually the BPMS is away only briefly, and the next attempt goes through. If the reason does not go away, the outbox gives up on the note and keeps it so that somebody can repair it. Then your application believes in a workflow which the BPMS never heard of. You find out in two places. The error written at that moment names the workflow module, the BPMN process and the aggregate. And vanillabp.outbox.blocked (the metrics) counts it. Alert on this number. Your platform's page says what the outbox promises, how long it tries, and how such a note is opened again: Spring Boot or Quarkus.

The process starts with the aggregate as it is when the start reaches the BPMS. That is not necessarily the state it had when your code called startWorkflow. Normally the two are only milliseconds apart, because no store waits for a note it has just written. But if a note stays behind and somebody else changes an attribute in the meantime, the first gateway reads the changed attribute. This is not an accident of the outbox. It is what a sync point is: a process runs in the present, and every later sync point also hands over the aggregate as it is then.

A workflow nobody started through VanillaBP

A process may also start without your application asking for it. A timer start event fires, once or on a cycle. A signal start event receives a broadcast. The condition of a conditional start event becomes true. And anybody with access to your BPMS can start any process directly: an operator, a test fixture, or an older integration which still starts the process the way it always did.

Such a workflow has no workflow aggregate, but it needs one. Tasks are routed by the aggregate's ID, and BPMN expressions read its attributes. VanillaBP does not build an aggregate by itself. An object created without your code carries none of your values, and here it would be the first thing that ever happens to the workflow. So your workflow service builds it:

@WorkflowStartedByBpms
public Settlement buildAggregate(final BpmsStartTrigger trigger, @TaskParam("region") final String region) {
  return new Settlement(UUID.randomUUID().toString(), region);
}

The aggregate the method returns names the workflow. The ID of a workflow is the ID of its workflow aggregate, and you assign it. Either you set it in the method. Or you leave it empty, and your persistence layer assigns one while saving, as it does for a @GeneratedValue ID. If the ID is still empty after saving, the start ends with a message which says so, because there would be nothing to call the workflow by.

The method may take a BpmsStartTrigger and process variables via @TaskParam, in any order. This is the same binding a @WorkflowTask method has. The workflow aggregate is not among them, because it does not exist before this method runs. The method runs in the transaction which VanillaBP opened for the start, and the aggregate is saved afterwards. If the method throws, the workflow does not start. The aggregate is rolled back, and your BPMS handles the failure as it handles any failing handler.

If you call aggregateChanged inside the method for the aggregate it builds, nothing happens. So code which reports every change it makes may run here unchanged. Nobody can find the workflow while the method runs, and the values the aggregate shares reach the BPMS anyway when the start is done. An aggregate counts as being built while it has no ID yet, or an ID your persistence does not know. If the method returns an aggregate which already exists, a call for it works as it does everywhere else.

If a process has several start events and the aggregate differs per event, name the start event:

@WorkflowStartedByBpms(id = "DailySettlementTimer")
public Settlement buildScheduledSettlement(@TaskParam("region") final String region) {
  return new Settlement(UUID.randomUUID().toString(), region);
}

Without an id, the method serves every start event of its process. If a method names a start event which the model does not have, the boot fails with a message which names both. Usually a start event was renamed, or the method is on the wrong workflow service.

The trigger says what fired, not when

BpmsStartTrigger carries the kind of the start event (NONE, MESSAGE, TIMER, SIGNAL or CONDITIONAL). For a signal it also carries the name of the signal. And it carries the BPMN ID of the start event.

It carries no time. No BPMS tells a start listener the time for which it scheduled the start, so an adapter would have to invent that value.

So when a start event fired is a value of your own. Model it as a process variable and fill it by an expression in the BPMN model. Then read it as a @TaskParam of a TASK behind the start event. Do not read it in the method which builds the aggregate. On Camunda 8 that method runs on a listener job of the start event itself, and such a job sees only the variables the instance was created with. A timer start creates none, so the value would not be there yet.

A moment written by an expression is not the same text on both engines. Camunda 7 writes 2026-09-26T08:08:29.728Z and Camunda 8 writes 2026-09-26T08:12:21.785@GMT. The reason is that FEEL prints the zone region instead of the offset, and the broker's zone decides which one. If the same workflow module has to run on both, keep the value as a String, or parse it where you read it.

When the method is required

A process needs the method if its BPMS fires a start event by itself: a timer, a signal or a condition. An application without the method does not boot, and the message names the process, the start event and the method to write. You learn this while the models are deployed, not when the timer fires at three in the morning. The check is made per start event. So a process with a timer start and a signal start cannot serve one of them and fire the other into nothing.

A plain start event or a message start event is not judged this way. Your own start has this shape, and asking for a method for it would refuse every ordinary process. If somebody still starts such a process past VanillaBP, that one start is refused, and the message names the method to write.

A start event of an event subprocess never reaches the method. It fires inside a workflow which is already running, and that workflow already has its aggregate (see event subprocesses).

What the BPMS has to support

BPMS How it works
Camunda 7 An execution listener on the start event runs inside the engine's own transaction, so the workflow aggregate and the process instance commit together. The instance's business key holds the aggregate's ID.
Camunda 8 The adapter adds an execution listener to the start event. Its job holds the workflow back, so nothing runs before the aggregate exists. The job is completed with the aggregate's ID and the values shared per @SyncWithBPMS as process variables.
Process-Engine-API Not supported: the API never reports a start which the application did not ask for. Deploying a process with a timer, signal or conditional start event fails with a message which names the element, because a workflow without an aggregate could never be processed.

Serving several versions of a process

@WorkflowStartedByBpms(version = ...) picks the method by the version of the deployed process definition from which the BPMS started the workflow. It works exactly like @WorkflowTask(version = ...) picks the method for a task: numbers, ranges and version tags, and overlapping specifications are reported when the application starts. A workflow which the BPMS starts on its own is created from the version the BPMS chose, so the same rule applies without an exception. The same is true for @WorkflowEnded(version = ...). If they name no range themselves, both take the range of their @BpmnProcess. So a class bound to one generation of a model does not repeat the range on its start and end methods either.

How VanillaBP tells the two apart

The kind of the start event decides nothing here. Anybody with access to the engine can start any process, and a timer start event says nothing about who started this one instance. What decides is the state of the workflow: does the BPMS already hold a name for it?

Where that name is kept differs per BPMS. Camunda 7 keeps it as the business key. Camunda 8 and the Process-Engine-API have no business key which VanillaBP uses. There the name lives in a process variable named after your aggregate's ID attribute. Every other part of VanillaBP reads a workflow by this same variable on those BPMS. VanillaBP asks for the business key first, and reads the variable if there is none. So one rule serves every BPMS.

There are three answers.

  • The BPMS holds a name, and a workflow aggregate carries it. Then the workflow is already yours, and nothing is built. This is your own start. It also makes a repeated notification harmless, because the second delivery finds the same aggregate.
  • The BPMS holds no name. Then nobody started this workflow through VanillaBP. Your @WorkflowStartedByBpms method builds the aggregate, and the name it chose is written into the BPMS.
  • The BPMS holds a name which no workflow aggregate carries. Then the start is refused. The next section is about this case.

Sometimes the BPMS holds a name which cannot even be an ID of your aggregate, for example a text against a numeric ID. This is also a name which no aggregate carries, and it is refused the same way. There is no separate case for it, because there is no separate outcome.

A name VanillaBP did not give

VanillaBP names a workflow, and nobody else does. Somebody may start a workflow with a business key of their own, or with the ID variable already filled. Such a workflow carries a name which no workflow aggregate of yours has, and VanillaBP refuses to work on it. The message names that value, where the BPMS keeps it, the workflow module, the BPMN process and the instance in your BPMS. VanillaBP only refuses. What follows is what your BPMS does with any failing handler: Camunda 7 and Camunda 8 retry, and raise an incident once the attempts are used up.

There are two ways out, and the message names both. Start the workflow through the ProcessService. This is the answer wherever your application can reach it. Or start it without a name of your own, and let the @WorkflowStartedByBpms method name it.

If the BPMS does not report such a start at all, your application notices it at the first task of that workflow. That delivery fails. It names the ID it looked for and the aggregate class under which it found nothing.

One case stays invisible, and no BPMS can change it. Somebody may choose a name which happens to be the ID of an aggregate you already have. Then that workflow is attached to that aggregate, and nothing is said. There is one value, it names an aggregate which exists, and this is exactly what your own start looks like.

This does not affect a workflow you took over when you upgraded from VanillaBP 1 on Camunda 7. It carries its aggregate's ID in the business key and nowhere else. VanillaBP reads exactly this name, and the aggregate is there. Nothing is refused, and your migrated workflows keep running.

When a workflow ends

This is the other end of the same story. Your application may want to know that a workflow finished. Modelling a service task in front of every end event just to learn this is BPMN noise. Annotate a method instead:

@WorkflowEnded
public void rideFinished(final Ride ride, final WorkflowEnd end) {
  ride.setClosedAt(end.time());
}

VanillaBP loads the workflow aggregate, calls the method and saves the aggregate. If a process ends in several places and you care which one it was, name an end event with @WorkflowEnded(id = "Event_Rejected"). Without an id, the method serves every end.

Nothing about it is mandatory, and a model costs nothing if no method exists. Every adapter attaches its listener only for processes whose workflow service declares such a method.

What you can rely on

  • At-least-once: a notification which is delivered again calls the method again. So make the method idempotent. Recording a status is idempotent.
  • The transaction depends on the BPMS. An embedded engine ends the workflow and calls your method in ONE transaction, so a failure rolls both back. A remote BPMS delivers the notification afterwards. A failure there means that the BPMS retries the delivery, not the workflow.
  • An aggregate which you already deleted is not an error. VanillaBP logs it and skips the notification.

A called process is part of the workflow

Take a process which you declare in secondaryBpmnProcesses and which a call activity starts. It runs on the workflow aggregate of the process which called it. It is a step of that workflow, not a workflow of its own. So its end does not call your @WorkflowEnded method, and neither does its cancelation by a boundary event on the call activity. Your method runs once, when the workflow at the top ends. The same holds for the start. The start of a called process builds no aggregate and never calls a @WorkflowStartedByBpms method. A called process with a timer, signal or conditional start event stops the start of your application, because nothing would build the aggregate of an instance which such an event creates.

The tasks of a called process still behave as before. They belong to the called process (see Workflow tasks). And if the end of a called instance removed an open task, that task is still reported as canceled.

A process is either a workflow of its own or a step of one workflow, never both. So classes of two workflow aggregates must not declare the same process. If they do, the start stops. Workflow tasks says how to decide, and what a call activity of a workflow of its own means.

One kind of secondary process keeps its own start and end: the id which a renamed process left behind, with no model in your application any more. The workflows which your BPMS still runs under that id were started under it. So for them, that id is the process at the top.

What each BPMS reports

BPMS Kind End event
Camunda 7 COMPLETED and CANCELED; the engine's delete reason tells apart an instance which somebody ended and one which ran to its end the element at which the instance ended; for a regular end this is the end event
Camunda 8 COMPLETED, and CANCELED from the 8.10 release line on; before that line the cluster runs end listeners of completed instances only, and a cancelled instance is removed without them not reported: the listener sits on the process, not on the end event
Process-Engine-API not supported; the API delivers tasks and reports nothing about an instance which ended. A @WorkflowEnded method for a process which runs there causes a WARN which names it; the workflow itself runs normally

The table shows the honest state, and it is not a promise. An adapter reports the kinds its BPMS can tell apart, and nothing more.

How a model looks decides nothing here. A terminate end event and an interrupting event subprocess read like a cancelation, but both Camunda engines report them as COMPLETED. Which modelled paths a BPMS calls a cancelation is up to that BPMS. So ask its adapter's documentation, not the model.

This cancel is not BPMN's cancel event

WorkflowEnd.Kind.CANCELED says one thing only: the workflow ended without reaching an end event. BPMN uses the same word for something quite different, a cancel event. A cancel event belongs to a transaction subprocess and may not be modelled anywhere else. The two have nothing to do with each other. No adapter of VanillaBP serves, rewrites or refuses a transaction subprocess, and each adapter's Deviations page says so. The word has the same other meaning in cancelTask, which sends a BPMN error and not a cancel event.

Clone this wiki locally