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) |
|
|
Message start event |
|
close to |
Timer start event |
|
|
Conditional start event |
|
richer here: |
Exclusive gateway ✕ |
|
first matching flow, else the default flow — identical semantics. Alternatives
converge again at an ordinary node; no |
Inclusive gateway ○ |
|
every matching flow; the join names the split it synchronises via |
Parallel gateway |
|
every flow, unconditionally; the join names the split it synchronises via |
User task with assignee / candidate group |
|
|
Interrupting boundary timer on a user task |
|
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 |
|
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 |
Non-interrupting timer boundary with a bounded cycle ( |
|
the count is an attribute rather than part of a cycle string, since the interval
is already |
Script task |
|
Groovy/JUEL becomes JavaScript against |
Service task (Java delegate, connector) |
|
JavaScript against |
Intermediate timer catch |
|
|
Intermediate conditional catch |
|
re-evaluated whenever the record changes; attach a |
Call activity |
|
names another definition, exactly as |
Embedded sub-process |
|
carries its graph rather than naming one, exactly as |
Multi-instance (sequential) |
|
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 |
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 |
Error boundary event |
|
on a |
End event |
|
|
Error end event |
|
ends the run as a failure under that name, which an error boundary on the
caller’s |
Mail task |
|
sends an existing Message email template for the record; runs in the
background unless |
Terminate end event |
|
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") |
|
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 |
|
|
Field access |
|
|
Process variable |
|
|
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 |
|
Message events and correlation |
|
Signal events (broadcast with no key) |
|
DMN business rule task |
Rewrite small decision tables as a |
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 |
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.