Migrating from the BPM module

The workflow engine is the successor to the Camunda-based BPM module, and it is built to be the place your existing processes land. The aim is that every construct a real BPM process uses has a direct, mechanical equivalent here — close to Camunda in shape, naming and observable behaviour, without being a BPMN implementation or claiming compatibility with it.

Today a legacy process is moved by rewriting it node by node against the map below. For the shapes people actually built in BPM — a gateway, a user task with a deadline, a timer, a service task, a parallel fork — that is a short exercise, because each one maps one-for-one.

The gain is that a workflow is now a first-class platform resource: authored in XML alongside views and actions, or drawn in the Studio designer, versioned with the module, and validated on save rather than at deploy time.

One difference to know before you start

Legacy BPM ran the process inside the transaction that saved the record: if the process failed, the business save rolled back with it.

This engine dispatches after the commit. The save always stands; a failure is recorded on the run and surfaced there, and never blocks the write that triggered it. This is deliberate — a broken workflow must not stop people from working — but it inverts a behaviour some BPM processes lean on. If a rule must prevent a save, it belongs where it always belonged: in a validation or a constraint on the model, not in the workflow.

This is the one difference a mapping table cannot express for you: a process that leaned on "the save rolls back when the process fails" keeps running after migration, but no longer rolls anything back. Check for it deliberately.

Element map

Everything in this table has a direct equivalent. See Nodes for the details of each.

Camunda / BPMN Workflow engine Notes

None start event (manual)

<start on="manual">

Message start event

<start message="…​">

close to correlateStartMessage, and different in the way the rest of this family is: every definition naming the message starts, rather than the first. The message’s key must be the started workflow’s own record id

Timer start event

<start on="timer" …​>

Conditional start event

<start on="create|update" watch when>

richer here: watch narrows the trigger to named fields

Exclusive gateway ✕

<split mode="one">

first matching flow, else the default flow — identical semantics. Alternatives converge again at an ordinary node; no <join> needed

Inclusive gateway ○

<split mode="some"> + <join wait="all">

every matching flow; the join names the split it synchronises via for

Parallel gateway

<split mode="all"> + <join wait="all">

every flow, unconditionally; the join names the split it synchronises via for

User task with assignee / candidate group

<task assignee|user|role|group>

Interrupting boundary timer on a user task

<boundary host on="timer" delay unit next>

the most-used boundary event, one-for-one, and under the same name: the approval is withdrawn and the run leaves by the boundary’s own edge, with no split needed to tell a deadline from a decision

Non-interrupting boundary event

<boundary cancel="false">, on a timer or a condition alike

one-for-one: the boundary fires without closing the park, and the run starts a detached escape branch alongside the original from the boundary’s own next

Non-interrupting timer boundary with a bounded cycle (R3/P1D)

<boundary cancel="false" delay="1" unit="day" repeatCount="3">

the count is an attribute rather than part of a cycle string, since the interval is already delay/unit. Unbounded cycles (R/P1D) have no equivalent — give a count, or drive the recurrence from a condition

Script task

<script>

Groovy/JUEL becomes JavaScript against self — see below

Service task (Java delegate, connector)

<script>

JavaScript against self; use the supported script helpers for service access

Intermediate timer catch

<wait on="timer" delay/unit | until>

until for a date, delay/unit for a duration

Intermediate conditional catch

<wait on="condition" when>

re-evaluated whenever the record changes; attach a <boundary> for a deadline, so it cannot wait forever on a record nobody touches

Call activity

<call ref>

names another definition, exactly as callActivity does. The caller waits for the child, including when the child parks on an approval, and wherever the caller itself stands — a branch of a parallel gateway waits on its call the same way it would wait on an approval of its own

Embedded sub-process

<subflow> with its nodes written inside it

carries its graph rather than naming one, exactly as subProcess does — and like one, it runs inside the instance that entered it rather than starting another. No identity of its own, deployed and revised with its caller, sharing the caller’s variables under the scope rule below. A <task> inside it is filed against the run’s record, as one inside a Camunda sub-process belongs to the root instance. Boundary events attach to it and watch the whole region, interrupting every thread inside — which is the part a migrating author checks first, and the reason this node exists. One level only — a sub-process inside a sub-process is a workflow of its own to <call>

Multi-instance (sequential)

<call> or <subflow> with each

one instance per element, and an element may park: each opens its own approval and the run waits for all of them, which is what nrOfInstances / nrOfCompletedInstances count for you there. Which node you pick decides what an element is: a <call>’s elements are separate runs, a `<subflow>’s are threads of the one run — so only the subflow can carry a deadline on the whole set. A `filter source still runs each element to completion

Group

— (nothing, deliberately)

a BPMN group is an annotation: it draws a box round some nodes and changes nothing about how they run. What people usually want from one — a region with its own scope that a boundary event can attach to — is <subflow>, and that one executes, boundary included

Error boundary event

<boundary on="error" code>

on a script, agent, email, call or subflow; code catches one named failure, and none catches any. See Catching a failure

End event

<end>

Error end event

<end error="CODE">

ends the run as a failure under that name, which an error boundary on the caller’s <call> can catch

Mail task

<email template>

sends an existing Message email template for the record; runs in the background unless async="false"

Terminate end event

<end terminate="true">

stops the run and withdraws the approvals still open

Sub-process with a terminate end, used as a race ("first of these wins, then carry on")

<split mode="all"> + <join wait="any">

the join continues the same run once the first branch arrives and cancels the rest, so no sub-process is needed to scope the cancellation — see Branching and Joining

History is deliberately Camunda-shaped, so a BPM administrator’s queries transfer: MetaWorkflowRun plays the part of ACT_HI_PROCINST, and MetaWorkflowTask that of ACT_HI_TASKINST.

Expressions

BPM expressions were JUEL (${…​}) or Groovy. Here every condition and script body is JavaScript evaluated against the record:

BPM Workflow engine

Condition

${order.totalAmount > 1000}

self.totalAmount > 1000

Field access

execution.getVariable("order")

self

Process variable

execution.setVariable("x", v)

vars.x = v

Relations are traversed directly (self.customer.email), and the full set of bindings — self, old, event, vars, task, agent, log, _t — is listed in Nodes (with parent, message, error and visit besides).

What variables carry, and what they do not

vars is the process variables, and like Camunda’s it is kept across a pause: a value set before a user task is still there after it, and a run that wakes days later reads what it wrote.

Two differences are worth knowing before you rely on them.

A record is stored as a reference, not as a copy. Writing vars.customer = self.customer writes down which customer, not what it looked like. When the run wakes, the reference reads the record as it is then — which is almost always what you want for an approval that sat in a queue for a week, and is the opposite of what a serialised copy would do.

The set of variables is bounded. A run’s variables are limited to workflow.vars.max-size characters of serialised JSON; a run whose variables grow past it fails when they are written down, and the message names the limit. Keep what the run needs to remember, and read the rest from the record.

There is no variable history. Camunda records every set and lets you query what a variable was at some earlier point. This engine keeps the current value only, written down when the run pauses, ends or fails. If an audit trail is what you need, write it to the record — track(note) leaves it on the record’s own message stream, which outlives the run.

Scope depends on which kind of child you wrote. A <call> is another definition, so it starts with variables of its own and reads its caller’s through parent.vars, which it may read and may not write; it reports back through what it saves, not by reaching upward. A <subflow> has no identity of its own and shares the caller’s variables directly, under Camunda’s own rule: a read walks outward, a write lands where the name is already found, and a name found nowhere is local to it. An accumulator named in the caller accumulates; a name first written inside the subflow is scratch and goes with it.

What has no equivalent yet

These are gaps, not boundaries: each is a construct we intend to cover, listed here with the way to model it in the meantime. None has a silent workaround — if a process needs one of these, you will know at authoring time rather than discovering it in production.

BPMN construct What to do instead

Event-based gateway

<split mode="all"> racing its branches, converged by a <join wait="any"> that lets the first arrival continue and cancels the rest — see Branching and Joining for worked examples. The race is over whatever the branches wait on, so "either of two approvals" and "an approval or a deadline" are the same shape. A race between a message and a deadline is the same shape too, since a <boundary message⇒ sits beside a <boundary on="timer"> on one host. For the single commonest case, "approve within N days or escalate", a <boundary> on the task is simpler than a race and stays the recommendation.

Message events and correlation

<send message= key⇒ announces, <wait message= key⇒ listens, and a <boundary message= key⇒ watches a park for one — see send. Correlation is by name and key rather than by a correlation-key expression on a message definition, and delivery is asynchronous, so a message sent before its receiver has parked still reaches it. A message may carry a <payload>, as Camunda’s process variables do, though it is a snapshot taken at send time rather than a merge into the receiver’s variables: it arrives as message.data and changes nothing about the record. One difference from Camunda worth planning for: a message goes to every run waiting on that name and key rather than failing on ambiguity (expect="one" reports a violation afterwards, it does not prevent one).

Signal events (broadcast with no key)

<send signal⇒ announces and <wait signal⇒ listens, with a <boundary signal⇒ to watch a park for one — see send. As in BPMN, a signal reaches every run listening for the name whatever record it is about, and is global rather than scoped to a process. Unlike Camunda it is a separate address space from a message rather than the same correlation with the key omitted: a signal never reaches a run waiting for one occurrence of that name, and a message never reaches one waiting for all of them. Signal start events are not available; a workflow still starts on its own trigger.

DMN business rule task

Rewrite small decision tables as a <script> or a <split mode="one">.

Compensation and BPMN transactions

Not available. Model the undo explicitly as its own path.

Escalation boundary events

Not available. Error boundary events are — see Catching a failure — but escalation, which travels up without ending the activity that raised it, has no equivalent. Model it as a normal outcome: a flag on the record and a <split> on it.

A worked example

A purchase approval, as it was commonly built in the BPM module: on submission, a manager and a credit check run in parallel; the manager has three days before the task escalates; a rejection from either side stops the whole thing.

In BPMN that is a parallel gateway, two lanes, a boundary timer on the manager task, an exclusive gateway on each outcome, and a terminate end. Node by node:

<workflow id="purchase-approval" name="purchase-approval"
          model="com.acme.db.PurchaseOrder">

  <start id="s" on="update" watch="submitted" when="self.submitted == true"
         repeat="episode" next="checks"/>                          (1)

  <split id="checks" mode="all" title="Approve and check">          (2)
    <flow id="f1" next="manager" title="Manager"/>
    <flow id="f2" next="credit" title="Credit"/>
  </split>

  <task id="manager" role="purchase.managers" next="managerOutcome"
            comment="none" title="Approve purchase"/>

  <boundary id="chase" host="manager" on="timer"
            delay="3" unit="day" next="escalate"/>                  (3)

  <split id="managerOutcome" mode="one">                             (4)
    <flow id="f1" when="task.rejected" next="stop" title="Rejected"/>
    <flow id="f2" default="true" next="merge"/>
  </split>

  <script id="escalate" next="merge"><![CDATA[
    track(_t('Purchase approval escalated'));
  ]]></script>
  <script id="credit" next="merge"><![CDATA[                        (5)
    vars.creditOk = self.supplier.creditRating >= 3;
  ]]></script>

  <end id="stop" terminate="true"/>                                 (6)
  <join id="merge" for="checks" wait="all" next="done"/>             (7)
  <script id="done" next="e"><![CDATA[
    self.status = 'CONFIRMED';
    save();
  ]]></script>
  <end id="e"/>
</workflow>
1 The conditional start event, with watch narrowing it to the field that matters.
2 The parallel gateway becomes an all-mode split. Its flows carry no conditions — both always happen.
3 The interrupting boundary timer becomes a <boundary>, attached to the approval and with its own outgoing edge, exactly as the diagram draws it.
4 What is left for this one-mode split is the decision itself: task.rejected for a rejection. A deadline no longer needs testing here, because it left by the boundary instead of coming through this node. Being a one split, it needs no join of its own — both its outcomes just flow into nodes that eventually reach merge.
5 A service task becomes a <script>.
6 The terminate end: a rejection here stops the run and withdraws the credit branch’s work, exactly as the BPMN diagram intends.
7 The converging gateway becomes a <join>, naming checks — the all split it synchronises — via for. Both of that split’s branches (manager’s approval path and `credit) must arrive here before done runs.

Two behavioural differences to expect when this runs:

  • The branches run one after another, in the order written, not at the same time. So does Camunda’s default; only its async continuations differ.

  • The run starts after the save commits. If the confirm action fails, the submission still stands and the failure is recorded on the run — the BPM module would have rolled the submission back with it.