Skip to main content

Structure

Structural elements group flow nodes into reusable scopes, invoke other processes, and iterate over collections.

ElementWhen to use it
Embedded sub-processGroup steps into an inline scope with isolated variables
Event sub-processReact to events inside a scope without disrupting the main flow
Ad-hoc sub-processActivate a dynamic set of activities chosen at runtime
Call activityInvoke another, separately-defined process as a child
Multi-instance loopRun an activity once per item in a collection
Standard loopRepeat an activity while a condition holds

Embedded sub-process

An inline scope. The sub-process has its own start and end events and contains any flow elements, including nested sub-processes.

Attributes

AttributeDefaultNotes
idRequired
nameOptional
triggeredByEventfalsetrue makes this an event sub-process
defaultID of the outgoing flow taken when every other outgoing condition is false. See Sequence Flow → Default flows

Child loop elements

ElementEffect
<multiInstanceLoopCharacteristics>Run the body once per item in a collection (or loopCardinality times) — see Multi-instance loop
<standardLoopCharacteristics>Re-run the entire body in series until loopMaximum is hit or loopCondition becomes false — see Standard loop

The two are mutually exclusive on the same activity.

Scope behavior

  • The sub-process scope has isolated variables. I/O mappings on the sub-process control what crosses the boundary.
  • Boundary events on the sub-process element interrupt or extend the entire sub-process — see Boundary events.

Example

<bpmn:subProcess id="payment-sub" name="Process Payment">
<bpmn:extensionElements>
<quantum:ioMapping>
<quantum:input source="=orderId" target="order_id" />
<quantum:output source="=paymentStatus" target="status" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>

<bpmn:startEvent id="sub-start"><bpmn:outgoing>sub-f1</bpmn:outgoing></bpmn:startEvent>
<bpmn:serviceTask id="charge" name="Charge Card">
<bpmn:extensionElements>
<quantum:taskDefinition type="payment-worker" />
</bpmn:extensionElements>
<bpmn:incoming>sub-f1</bpmn:incoming>
<bpmn:outgoing>sub-f2</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:endEvent id="sub-end"><bpmn:incoming>sub-f2</bpmn:incoming></bpmn:endEvent>
<bpmn:sequenceFlow id="sub-f1" sourceRef="sub-start" targetRef="charge" />
<bpmn:sequenceFlow id="sub-f2" sourceRef="charge" targetRef="sub-end" />
</bpmn:subProcess>

Validation

CheckSeverity
Sub-process contains no start eventError
Sub-process contains no end eventWarning

Event sub-process

A <subProcess triggeredByEvent="true"> lives inside a parent scope but isn't connected by sequence flows. It's started by its own typed start event when a matching event happens in the parent scope.

Differences from a normal sub-process

PropertyNormalEvent sub-process
triggeredByEventfalsetrue
Sequence flow connectionHas incoming/outgoing flowsNone — triggered by its start event
Start event typeNoneTyped (message, timer, signal, error, escalation, conditional)
Start event isInterruptingN/Atrue cancels the parent, false runs in parallel

Compensation start events are not supported in event sub-processes — see Events FAQ.

Examples

<!-- Interrupting message-triggered event sub-process -->
<bpmn:subProcess id="esp-cancel" triggeredByEvent="true">
<bpmn:startEvent id="esp-start" name="Order Cancelled" isInterrupting="true">
<bpmn:outgoing>esp-f1</bpmn:outgoing>
<bpmn:messageEventDefinition messageRef="Msg_Cancel" />
</bpmn:startEvent>
<bpmn:serviceTask id="esp-cleanup" name="Cleanup Order">
<bpmn:extensionElements>
<quantum:taskDefinition type="cleanup-worker" />
</bpmn:extensionElements>
<bpmn:incoming>esp-f1</bpmn:incoming>
<bpmn:outgoing>esp-f2</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:endEvent id="esp-end"><bpmn:incoming>esp-f2</bpmn:incoming></bpmn:endEvent>
<bpmn:sequenceFlow id="esp-f1" sourceRef="esp-start" targetRef="esp-cleanup" />
<bpmn:sequenceFlow id="esp-f2" sourceRef="esp-cleanup" targetRef="esp-end" />
</bpmn:subProcess>

<!-- Non-interrupting conditional event sub-process: fires on each false→true transition -->
<bpmn:subProcess id="esp-watch" triggeredByEvent="true">
<bpmn:startEvent id="esp-cond-start" isInterrupting="false">
<bpmn:outgoing>esp-c1</bpmn:outgoing>
<bpmn:conditionalEventDefinition>
<bpmn:condition xsi:type="bpmn:tFormalExpression">priority = "high"</bpmn:condition>
</bpmn:conditionalEventDefinition>
</bpmn:startEvent>
<!-- ... -->
</bpmn:subProcess>

Validation

CheckSeverity
Event sub-process has incoming or outgoing sequence flowsError
Event sub-process has more or fewer than one typed start eventError
Conditional start event has empty conditionError
Error / escalation start event placed outside an event sub-process (BPMN 2.0 §10.4.2)Error
Compensation start event used anywhereError (not supported)

Ad-hoc sub-process

An ad-hoc sub-process contains activities whose activation is dynamic — the set of activities to spawn is chosen at runtime by a FEEL expression rather than entered through a start event. Sequence flows between inner activities are still supported, so once activated, activities can flow into each other in the usual way.

Attributes and extensions

Attribute / ExtensionNotes
default (attribute)ID of the outgoing flow taken when every other outgoing condition is false. See Sequence Flow → Default flows
<quantum:adHoc activeElementsCollection="...">FEEL expression returning the list of activity IDs to activate
<completionCondition> childFEEL expression evaluated after each activity completes, the sub-process ends when it returns true
<multiInstanceLoopCharacteristics>Run the entire ad-hoc once per item in a collection
<standardLoopCharacteristics>Re-run the entire ad-hoc until loopMaximum / loopCondition stop. Mutually exclusive with multi-instance

Each activity inside an ad-hoc sub-process must carry a quantum:taskDefinition (or be one of the supported task types).

Behavior with end events

End events behave differently inside an ad-hoc sub-process:

End event variantEffect
None / message / signal / escalationConsumes the token, the ad-hoc keeps running until completionCondition is satisfied
TerminateEnds the ad-hoc scope immediately, cancelling all sibling tokens, the parent process continues from the ad-hoc's outgoing flow

Example

<bpmn:adHocSubProcess id="ad-hoc-tasks" name="Dynamic Tasks">
<bpmn:extensionElements>
<quantum:adHoc activeElementsCollection="=[&quot;task-review&quot;, &quot;task-approve&quot;]" />
<quantum:ioMapping>
<quantum:output source="=allCompleted" target="allDone" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>

<bpmn:serviceTask id="task-review" name="Review">
<bpmn:extensionElements>
<quantum:taskDefinition type="review-worker" />
</bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:serviceTask id="task-approve" name="Approve">
<bpmn:extensionElements>
<quantum:taskDefinition type="approve-worker" />
</bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:completionCondition xsi:type="bpmn:tFormalExpression">=reviewDone and approveDone</bpmn:completionCondition>
</bpmn:adHocSubProcess>

Validation

CheckSeverity
Ad-hoc sub-process contains no activitiesError
Ad-hoc sub-process contains a start eventError
ordering="Sequential" (or any value other than "Parallel")Error

Call activity

Invokes a separately-defined process as a child. The child runs in its own scope, the parent waits until the child completes.

Attributes and extensions

Attribute / ExtensionRequiredNotes
calledElement (attribute)Standard BPMN attribute, ID of the called process. Overridden by the extension when both are present
default (attribute)noID of the outgoing flow taken when every other outgoing condition is false. See Sequence Flow → Default flows
quantum:calledElement processId="..."yesID of the process to invoke
quantum:calledElement bindingType="..."no"" or "latest" (default) picks the newest deployed version, "version" pins to a specific integer version.
quantum:calledElement version="N"noRequired when bindingType="version". Positive integer
quantum:calledElement propagateAllChildVariables="..."notrue merges all child output variables into the parent scope. Default is false
quantum:ioMappingnoInputs are passed to the child, outputs are pulled back
multiInstanceLoopCharacteristicsnoRun the called process once per item — see Multi-instance loop
standardLoopCharacteristicsnoRe-call the child process serially up to loopMaximum times. Mutually exclusive with multi-instance — see Standard loop

A call activity cannot be a compensation handler. isForCompensation="true" is rejected at deployment — only tasks and embedded sub-processes are valid compensation handlers.

Variable propagation

ModeWhat flows back
propagateAllChildVariables="false" (default)Only variables explicitly mapped via output mappings
propagateAllChildVariables="true"All child variables are merged into the parent scope on completion

Boundary events on the call activity are supported. An interrupting boundary cancels the child workflow.

Compensation

A call activity participates in compensation in one of two ways, and they are mutually exclusive:

The call activity has…Compensating the call activity runs…
A compensation boundary event + handler (the handler lives in the parent)Only that parent-side handler. The handler defines the call activity's black-box compensation; the child's own internal compensation is not invoked.
No compensation boundaryCompensation propagates into the called process and runs the child's own internal compensation handlers.

Propagating compensation into a called process is an intentional difference from Camunda 8, which stops compensation at the call activity. To get the Camunda behavior, attach a compensation boundary handler to the call activity itself.

A compensation throw inside the child is independent of both — it runs during the child's own execution whenever its flow reaches the throw.

Because a compensable child must stay available until the parent might compensate it, the engine keeps it alive after it finishes its own flow. It is released and reaches Completed as soon as the parent no longer needs it — when the parent reaches a terminal state, or when the call activity's output mapping raises an incident. A compensable child never outlives its parent.

Example

<bpmn:callActivity id="run-sub-process" name="Run Sub-Process">
<bpmn:extensionElements>
<quantum:calledElement processId="child-process-id" propagateAllChildVariables="false" />
<quantum:ioMapping>
<quantum:input source="=customerId" target="customer_id" />
<quantum:output source="=result" target="subProcessResult" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
</bpmn:callActivity>

Validation

CheckSeverity
processId and calledElement are both emptyError
isForCompensation="true" set on the call activityError (only tasks and embedded sub-processes can be compensation handlers)
bindingType="version" without a positive integer versionError

Multi-instance loop

Attached as <multiInstanceLoopCharacteristics> to a task, sub-process, or call activity. Runs the host activity multiple times — in parallel or sequentially.

Attributes and child elements

Attribute / ElementNotes
isSequential (attribute)true runs iterations one after another, false (default) runs them in parallel
<loopCardinality> childFEEL expression returning the number of iterations (e.g. "5", "=items.size()")
<completionCondition> childFEEL expression, the loop exits early when it returns true

The quantum:loopCharacteristics extension holds collection-based iteration:

AttributeNotes
inputCollectionFEEL expression returning the collection to iterate over (e.g. "=orderItems")
inputElementVariable name for the current item in each iteration (e.g. "item")
outputCollectionVariable name to collect per-iteration results into
outputElementFEEL expression extracting the result from each iteration scope (e.g. "=result")

<loopCardinality> and inputCollection are mutually exclusive — pick one. outputCollection / outputElement only make sense with inputCollection.

Parallel cardinality — static safety net

A literal-integer loopCardinality greater than 1000 on a parallel multi-instance activity is rejected at deployment. This is a static safety net to catch obvious mistakes like loopCardinality="100000" — every parallel instance becomes a live token plus scope in workflow state, and very large counts blow past the workflow engine's per-payload size limits.

A loopCardinality that is a FEEL expression (e.g. =items.size()) bypasses this static check — the engine cannot know the result at deploy time and there is no runtime cap. If the expression resolves to a very large number, the engine will faithfully create that many instances and the workflow will eventually fail with a payload-size or resource error rather than a clean cardinality-exceeded error. In practice this means:

  • Keep parallel multi-instance bounded. A few dozen to a few hundred instances is fine, thousands risks instability.
  • For collections of unknown size, prefer isSequential="true" (one instance at a time, no fan-out cost).
  • For large known fan-out, break the collection into batches with two nested multi-instance activities (outer loops over chunks, inner runs the chunk in parallel), or push the iteration into a streaming worker that loops internally.

Variables visible inside the loop

The engine injects spec-standard counters that any FEEL expression inside the activity can read:

VariableScopeDescription
numberOfInstancesOuterTotal inner instances created
numberOfActiveInstancesOuterCurrently executing instances (always 0 or 1 for sequential)
numberOfCompletedInstancesOuterInstances completed normally so far
numberOfTerminatedInstancesOuterInstances cancelled or interrupted
loopCounterPer instance1-based sequence number of the current instance

A completionCondition can use any of these to exit early:

numberOfCompletedInstances > 0          // exit after the first finishes
numberOfCompletedInstances >= 2 // exit once at least two have finished
loopCounter = 1 and numberOfInstances > 3 // exit after instance 1 in a large batch

Variable visibility after the loop completes

The injected counters and loopCounter live on the multi-instance scopes — they are never propagated into the activity's outer scope when the loop finishes. The only thing that flows out is the configured outputCollection.

outputCollection is positional and always the same length as the cardinality. The engine pre-allocates one slot per planned instance and only normally-completed instances write into their slot — terminated, cancelled, errored, or never-started instances leave their slot as null. The list shape mirrors the input one-for-one, with null marking absent results.

If you need counts after the activity, derive them from non-null entries:

WantFEEL on the next activity
Total slots (equals the cardinality / inputCollection size)=count(outputCollection)
Instances that completed normally=count(outputCollection[item != null])
Instances that produced no result (cancelled, terminated, errored, or never started)=count(outputCollection[item = null])

The positional alignment also means you can correlate input and output by index: outputCollection[i] is the result of running the activity on inputCollection[i], or null if that instance didn't complete normally.

Boundary events on a multi-instance activity

A boundary event attached to an MI activity sees the counters via the normal scope chain — at the moment the boundary fires, the firing instance's parent is the MI wrapper, so the boundary handler's input/output FEEL can read numberOfCompletedInstances, numberOfActiveInstances, loopCounter, etc. directly. Once the MI scope cleans up these values are gone, so capture anything you need into the parent scope via the boundary handler's <output> mapping.

<bpmn:boundaryEvent id="mi-cancel" attachedToRef="process-items">
<bpmn:extensionElements>
<quantum:ioMapping>
<quantum:output source="=numberOfCompletedInstances" target="completedBeforeCancel" />
<quantum:output source="=numberOfTerminatedInstances" target="cancelledCount" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:errorEventDefinition errorRef="ItemErr" />
</bpmn:boundaryEvent>

Examples

<!-- Parallel multi-instance service task iterating a collection -->
<bpmn:serviceTask id="process-items" name="Process Each Item">
<bpmn:extensionElements>
<quantum:taskDefinition type="item-processor" />
<quantum:ioMapping>
<quantum:output source="=itemResult" target="itemResult" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:multiInstanceLoopCharacteristics>
<bpmn:extensionElements>
<quantum:loopCharacteristics
inputCollection="=orderItems"
inputElement="item"
outputCollection="processedItems"
outputElement="=itemResult" />
</bpmn:extensionElements>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:serviceTask>

<!-- Sequential multi-instance call activity -->
<bpmn:callActivity id="call-each" name="Call Sub Per Item">
<bpmn:extensionElements>
<quantum:calledElement processId="item-sub-process" propagateAllChildVariables="false" />
<quantum:ioMapping>
<quantum:input source="=item" target="currentItem" />
<quantum:output source="=subResult" target="subResult" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:multiInstanceLoopCharacteristics isSequential="true">
<bpmn:extensionElements>
<quantum:loopCharacteristics
inputCollection="=batches"
inputElement="item"
outputCollection="results"
outputElement="=subResult" />
</bpmn:extensionElements>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:callActivity>

Validation

CheckSeverity
Both loopCardinality and inputCollection are setError
Neither loopCardinality nor inputCollection is setError
multiInstanceLoopCharacteristics and standardLoopCharacteristics are both set on the same activityError
Literal-integer loopCardinality exceeds 1000 on a parallel multi-instanceError (static safety net, FEEL-expression cardinalities bypass this check)

Standard loop

A standard loop re-executes the same activity sequentially while a FEEL condition holds, capped by a maximum iteration count. Supported on:

Attributes and child elements

Attribute / ElementNotes
loopMaximum (attribute)Required. Maximum number of iterations (≥ 1). The loop ends when loopCounter reaches this value, regardless of the condition
testBefore (attribute)If true, the condition is checked before the first iteration — the activity is skipped entirely if the condition is false at that point. Default false
<loopCondition> childOptional FEEL expression. The loop continues as long as it returns true. Without it, the activity runs exactly loopMaximum times

Each iteration sets a loopCounter variable in the activity's surrounding scope (1-based). For sub-processes, the body re-enters in a freshly minted scope each iteration, for call activities, a fresh child workflow is started each time.

Behavior

SettingBehavior
testBefore="false" (default)Run at least once, check the condition after each completion
testBefore="true"Check first, skip entirely if the condition is false
loopMaximum="N"Hard cap — after N executions the loop exits
No loopConditionRun exactly loopMaximum times

Example

<!-- Run while counter < 5, capped at 10 iterations -->
<bpmn:serviceTask id="retry-task" name="Retry Until Done">
<bpmn:extensionElements>
<quantum:taskDefinition type="retry-processor" />
<quantum:ioMapping>
<quantum:output source="=counter" target="counter" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:standardLoopCharacteristics loopMaximum="10">
<bpmn:loopCondition>=counter &lt; 5</bpmn:loopCondition>
</bpmn:standardLoopCharacteristics>
</bpmn:serviceTask>

Validation

CheckSeverity
loopMaximum is missing or ≤ 0Error
Both standardLoopCharacteristics and multiInstanceLoopCharacteristics are setError

When to use which

Use a standard loop when iterations are sequential and condition-driven, and the count isn't known up front. Use a multi-instance loop when you need parallel execution or are iterating a known collection.


FAQ

What's the difference between a sub-process and a call activity in BPMN?

A sub-process is defined inline inside a parent process — its activities live within the parent's BPMN file and share its deployment lifecycle. A call activity invokes a separately-defined process by ID, the called process is deployed independently, runs in its own workflow instance, and the parent waits for the child to complete before advancing. Use a sub-process to group related steps for clarity or to scope variables and boundary events, use a call activity to factor out a reusable process that can be invoked from multiple places, deployed and versioned separately, and monitored as its own instance.

What is a BPMN event sub-process and when should I use one?

An event sub-process is a sub-process inside a parent scope that is not connected by sequence flows — it is set up by adding triggeredByEvent="true" to a <subProcess> element. It starts when its own typed start event fires in response to a matching event in the parent scope: a message arrives, a timer expires, a signal is broadcast, an error is thrown, an escalation is raised, or a conditional FEEL expression becomes true. Use it to react to events that can occur at any point inside the parent scope — for example, an order-cancelled message while the order is being processed — without disrupting the main flow. Do not confuse it with the event-based gateway, which is a routing construct that picks one of several events to wait for at a specific point in the flow.

What's the difference between interrupting and non-interrupting event sub-processes?

It is set by the isInterrupting attribute on the event sub-process's start event. With isInterrupting="true" (the default), the parent scope is cancelled when the event sub-process fires — every active token in the parent stops, and only the event sub-process's flow continues. With isInterrupting="false", the parent scope keeps running and the event sub-process runs in parallel on every matching event. Use interrupting for events that should abort the main flow (order cancellation, fatal error), and non-interrupting for parallel reactions that should not disrupt the main flow (periodic reminders, escalations to a supervisor).

What is an ad-hoc sub-process in BPMN?

An ad-hoc sub-process contains a set of activities that are not connected by fixed sequence flows. Instead, which activities run is decided at runtime by the <quantum:adHoc activeElementsCollection> FEEL expression, which returns the IDs of activities to activate. The sub-process completes when its <completionCondition> FEEL expression evaluates to true. Use it to model dynamic work where the order or selection of steps depends on data rather than a predefined flow — for example, a case-management workflow where the case worker picks which optional tasks to perform from a menu of allowed actions.

Does QuantumBPM support sequential ad-hoc sub-processes?

It depends on what you mean. The ordering="Sequential" attribute on an ad-hoc sub-process — the engine-level scheduling mode that would serialise activations of independent activities — is not supported and is rejected at deployment, only the default ordering="Parallel" is accepted. But you can absolutely express sequential behaviour by drawing sequence flows between activities inside the ad-hoc. BPMN 2.0 §10.3.5 includes sequence flows in the ad-hoc content model, and the engine applies normal sequence-flow mechanics to whatever you connect — so a chain A → B → C inside an ad-hoc runs sequentially even though the attribute itself is unsupported. So: no on the attribute, yes via internal flow modelling.

Is there a limit on the number of parallel multi-instance activity instances?

There is a static safety net: a literal-integer loopCardinality greater than 1000 on a parallel multi-instance is rejected at deployment, because every parallel instance becomes a live token plus scope in workflow state and very large counts exceed the workflow engine's per-payload size limits. A FEEL-expression loopCardinality (e.g. =items.size()) bypasses this check — the engine cannot know the result at deploy time and there is no equivalent runtime cap, so if the expression resolves to a very large number the engine will create that many instances and the workflow will eventually fail with a payload-size or resource error rather than a clean cardinality-exceeded error. Best practice: keep parallel multi-instance bounded, for unknown-size collections prefer sequential multi-instance, and for large known fan-out break the collection into batches with two nested multi-instance activities or process the iteration inside a streaming worker.

What's the difference between a multi-instance loop and a standard loop in BPMN?

A multi-instance loop runs the host activity a known number of times — either loopCardinality (an explicit count) or once per item in inputCollection — and can run iterations in parallel (isSequential="false", the default) or sequentially. A standard loop runs the host activity repeatedly while a FEEL loopCondition returns true, capped at loopMaximum iterations and always sequential. Use multi-instance when you know the iteration count or are processing a collection, especially when iterations can run in parallel. Use a standard loop for condition-driven repetition where the count isn't known up front — retry until success, poll until ready, page until empty. The two characteristics are mutually exclusive on the same activity.

What does propagateAllChildVariables do on a BPMN call activity?

It controls what flows back from the child process when it completes. With propagateAllChildVariables="false" (the default), only variables that the parent explicitly pulls back through <quantum:output> mappings on the call activity are merged into the parent scope. With propagateAllChildVariables="true", every variable the child accumulated in its root scope is merged into the parent scope on completion, overwriting parent variables of the same name. The default is safer for loose coupling — the parent declares exactly which child outputs it cares about — and is recommended for reusable child processes, the true setting is convenient for tightly-coupled processes where the parent expects to inherit child state wholesale.

What happens when a terminate end event fires inside a sub-process?

It cancels the nearest enclosing activity scope — the sub-process that contains the terminate, not the entire process instance. Every active token inside that sub-process stops, and execution continues from the sub-process's outgoing sequence flow as if the sub-process had completed normally. A terminate inside an embedded sub-process ends only that sub-process, a terminate at the root of the process ends the entire instance, a terminate inside a called process (a child workflow started by a call activity) ends only the called process, and the calling parent advances normally as if the child had completed.