12. Multi-Instance Tasks
Multi-instance tasks execute multiple timesβeither in parallel or sequentiallyβbased on a collection of items. This is useful for scenarios where the same task needs to be completed by multiple users (e.g., surveys, parallel approvals).
How It Works
Add <multiInstanceLoopCharacteristics> inside a <userTask> to create multiple task instances:
| Attribute | Description |
|---|---|
isSequential |
false = parallel (all at once), true = sequential (one after another) |
flowable:collection |
Variable containing the list to iterate over |
flowable:elementVariable |
Variable name for the current item in each iteration |
Example: Parallel Task for All Users in a Role
<!-- Step 1: Fetch users with ROLE_REVIEWER into a list variable -->
<serviceTask id="fetchReviewers" flowable:delegateExpression="${assignUsersByRole}">
<extensionElements>
<flowable:field name="roleName" stringValue="ROLE_REVIEWER"/>
<flowable:field name="outputVariable" stringValue="reviewerList"/>
</extensionElements>
</serviceTask>
<sequenceFlow sourceRef="fetchReviewers" targetRef="parallelReview"/>
<!-- Step 2: Create a task for EACH reviewer in the list -->
<userTask id="parallelReview" name="Review Document" flowable:assignee="${reviewer}">
<multiInstanceLoopCharacteristics isSequential="false"
flowable:collection="reviewerList"
flowable:elementVariable="reviewer"/>
<extensionElements>
<activiti:formProperty id="feedback" name="Your Feedback" type="string" required="true">
<activiti:value id="type" name="textarea"/>
</activiti:formProperty>
</extensionElements>
</userTask>
Behavior: If
reviewerListcontains 3 users, three parallel tasks are createdβone for each reviewer. The process only continues after all instances are completed.
Sequential Multi-Instance
For sequential execution (one task at a time), set isSequential="true":
<userTask id="sequentialApproval" name="Approval" flowable:assignee="${approver}">
<multiInstanceLoopCharacteristics isSequential="true"
flowable:collection="approverList"
flowable:elementVariable="approver"/>
</userTask>
Use Case: Chain of command approvals where each level must approve before the next sees the task.
Parallel Task for Hardcoded Users (Script Task)
If you know the exact users at design time and want each of them to receive a parallel task, use a Script Task to build the list:
<!-- Step 1: Build the user list -->
<scriptTask id="buildList" name="Build User List" scriptFormat="super-js">
<script>
var list = new java.util.ArrayList();
list.add('jane@workingflow.com');
list.add('jess@workingflow.com');
list.add('jade@workingflow.com');
execution.setVariable('reviewerList', list);
</script>
</scriptTask>
<sequenceFlow sourceRef="buildList" targetRef="parallelTask"/>
<!-- Step 2: Create a task for EACH user in the list -->
<userTask id="parallelTask" name="Review Document" flowable:assignee="${reviewer}">
<multiInstanceLoopCharacteristics isSequential="false"
flowable:collection="reviewerList"
flowable:elementVariable="reviewer"/>
<extensionElements>
<activiti:formProperty id="feedback" name="Your Feedback" type="string" required="true">
<activiti:value id="type" name="textarea"/>
</activiti:formProperty>
</extensionElements>
</userTask>
When to use: You know exactly which users should receive the task (e.g., a fixed review board). No
assignUsersByRoledelegate needed β the Script Task builds the list directly.
Parallel Task from Checkbox Selection (Script Task)
If a previous task uses a users_* checkbox field (multi-select) to pick users, the selected values are stored as a comma-separated string (e.g., "jane@work.com,jess@work.com"). Multi-instance requires a java.util.List, so a Script Task converts the string:
<!-- Previous task has a users_* checkbox field -->
<userTask id="pickReviewers" name="Select Reviewers" flowable:assignee="${initiator}">
<extensionElements>
<activiti:formProperty id="users_reviewers" name="Select Reviewers" type="enum" required="true"/>
</extensionElements>
</userTask>
<sequenceFlow sourceRef="pickReviewers" targetRef="splitUsers"/>
<!-- Step 1: Convert comma-separated string to a list -->
<scriptTask id="splitUsers" name="Prepare Reviewer List" scriptFormat="super-js">
<script>
var selected = execution.getVariable('users_reviewers');
var list = new java.util.ArrayList(java.util.Arrays.asList(selected.split(',')));
execution.setVariable('reviewerList', list);
</script>
</scriptTask>
<sequenceFlow sourceRef="splitUsers" targetRef="parallelReview"/>
<!-- Step 2: Create a task for EACH selected user -->
<userTask id="parallelReview" name="Review" flowable:assignee="${reviewer}">
<multiInstanceLoopCharacteristics isSequential="false"
flowable:collection="reviewerList"
flowable:elementVariable="reviewer"/>
<extensionElements>
<activiti:formProperty id="comment" name="Your Comment" type="string" required="true">
<activiti:value id="type" name="textarea"/>
</activiti:formProperty>
</extensionElements>
</userTask>
Key point: The
users_*prefix stores a comma-separated string, butflowable:collectionneeds ajava.util.List. The Script Task bridges this gap withjava.util.Arrays.asList(selected.split(',')).
π¦ Variable Aggregation β Collecting Parallel Results
When parallel multi-instance tasks collect responses (e.g., polls, feedback rounds), Variable Aggregation is a native Flowable feature that automatically gathers each user's input into a single JSON array variable. Combined with the list type hint, the aggregated results render as individual read-only labels β one per response.
How the Pieces Fit Together
The pattern involves 4 connected elements. Here is the full annotated example:
<!-- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
<!-- STEP 1: Multi-Instance User Task with Variable Aggregation -->
<!-- -->
<!-- flowable:assignee="${user}" -->
<!-- ββ "user" MUST match the elementVariable below -->
<!-- -->
<!-- HARD CONSTRAINT: <extensionElements> MUST appear BEFORE -->
<!-- <multiInstanceLoopCharacteristics> inside a <userTask>. -->
<!-- This is an XSD validation rule β reversing them causes -->
<!-- deployment failure. -->
<!-- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
<userTask id="giveArgument" name="Share Your Thoughts" flowable:assignee="${user}">
<extensionElements>
<!-- Form fields for each parallel task instance -->
<activiti:formProperty id="argument" name="Your Argument" type="string" required="true">
<activiti:value id="type" name="textarea"/>
</activiti:formProperty>
</extensionElements>
<!-- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
<!-- Multi-Instance Configuration -->
<!-- -->
<!-- flowable:collection="userList" -->
<!-- ββ The java.util.List variable (built by a prior step) -->
<!-- -->
<!-- flowable:elementVariable="user" -->
<!-- ββ Each item from the list, used in assignee="${user}" -->
<!-- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
<multiInstanceLoopCharacteristics isSequential="false"
flowable:collection="userList"
flowable:elementVariable="user">
<!-- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
<!-- Variable Aggregation (inside multiInstance, NOT userTask) -->
<!-- -->
<!-- target="allArguments" -->
<!-- ββ Name of the resulting JSON array variable. -->
<!-- After all tasks complete, this variable holds: -->
<!-- [{"argument":"...","username":"..."}, {...}, ...] -->
<!-- -->
<!-- <variable source="argument" target="argument"/> -->
<!-- ββ source: the form field ID to collect from each task -->
<!-- ββ target: the key name in the resulting JSON object -->
<!-- -->
<!-- <variable sourceExpression="${user}" target="username"/> -->
<!-- ββ sourceExpression: evaluated per instance -->
<!-- ββ CONVENTION: target="username" is a reserved key. -->
<!-- The renderer looks for this key, resolves the email -->
<!-- to a full name, and uses it in the label header. -->
<!-- The email value is excluded from the display. -->
<!-- ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
<extensionElements>
<flowable:variableAggregation target="allArguments">
<variable source="argument" target="argument"/>
<variable sourceExpression="${user}" target="username"/>
</flowable:variableAggregation>
</extensionElements>
</multiInstanceLoopCharacteristics>
</userTask>
Step 2: Convert ArrayNode to String (Required)
Flowable stores the aggregated variable as a Jackson ArrayNode object, but StringFormType (used by form properties with type="string") expects a String. A one-line Script Task converts it:
<scriptTask id="convertResults" name="Prepare Results" scriptFormat="super-js">
<script>
var raw = execution.getVariable('allArguments');
execution.setVariable('allArguments', raw.toString());
</script>
</scriptTask>
Why is this needed? Without this conversion, opening the review task throws a
ClassCastException: ArrayNode cannot be cast to String. This is a Flowable form engine limitation, not a Workingflow issue.
Step 3: Display with the list Type Hint
The list type hint tells FormRenderingService to expand the JSON array into multiple labels:
<userTask id="reviewAll" name="Review All" flowable:assignee="${initiator}">
<extensionElements>
<activiti:formProperty id="allArguments" name="Employee Argument" type="string" writable="false">
<activiti:value id="type" name="list"/>
</activiti:formProperty>
</extensionElements>
</userTask>
What the renderer does:
- Parses the JSON array:
[{"argument":"Great idea","username":"jane@work.com"}, ...] - For each entry, checks if the
usernamekey exists - If found β looks up the user's full name from the database β header becomes
Employee Argument (Jane Doe)and the email is excluded from the value - If no
usernamekey β header falls back toEmployee Argument (1),(2), etc. - Remaining fields are shown as the label content
Result (4 users responded):
Employee Argument (Jenn Employee) β "I fully support this idea"
Employee Argument (Jill Employee) β "Needs more research first"
Employee Argument (Joye Employee) β "Let's do a pilot run"
Employee Argument (Janette Employee) β "Approved, go ahead"
Key Constraints Summary
| Constraint | Why |
|---|---|
<extensionElements> BEFORE <multiInstanceLoopCharacteristics> |
XSD validation rule β reversed order fails deployment |
<flowable:variableAggregation> inside <multiInstanceLoopCharacteristics> |
It is scoped to the multi-instance loop, not the task itself |
source in <variable> must match a form field id |
That is where Flowable reads the submitted value from |
flowable:elementVariable must match flowable:assignee="${...}" |
The loop variable assigns each task to the correct user |
| Script Task to convert ArrayNode β String | Flowable's StringFormType cannot handle ArrayNode objects |
list type hint uses standard type="string" |
The Flowable type stays string β only the rendering hint changes |
target="username" is a convention |
The renderer resolves this value to a full name for the label header |
Reference implementation: See the
idea_poll.bpmnprocess for a complete working example using all of these features together. It also demonstratesgeneratePdfβ after all arguments are collected, a styled PDF summary is generated and offered as a download in the final review task.
π Dynamic Item Collection β Textarea-to-List Pattern
While Variable Aggregation collects data from multiple users (one item per user), there are cases where a single user needs to enter a variable number of items β expense lines, checklist items, action points, etc. BPMN forms are inherently fixed (one property = one field), but this pattern achieves dynamic data collection using only existing features.
How It Works
The pattern chains three existing capabilities:
Textarea (user enters N items) β Script Task (parses into JSON array) β list type hint (displays N labels)
No new conventions, no new type hints, no platform changes β just a creative use of textarea + Script Task + list.
Step 1: Collect Items with a Textarea
Use a textarea type hint and a <documentation> note to tell the user the expected format:
<userTask id="enterItems" name="Enter Expense Items" flowable:assignee="${initiator}">
<documentation>Enter each expense on a new line (or separate with ;). Format: item name, amount. Example: Taxi to airport, 25</documentation>
<extensionElements>
<activiti:formProperty id="expenseLines" name="Expense Items" type="string" required="true">
<activiti:value id="type" name="textarea"/>
</activiti:formProperty>
</extensionElements>
</userTask>
The user types freely β any number of lines:
Taxi to airport, 25
Hotel 2 nights, 300
Client dinner, 85
Or semicolons on one line: Taxi, 25; Hotel, 300; Dinner, 85
Step 2: Parse with a Script Task
A Script Task splits the text into structured JSON. This example parses "item name, amount" pairs:
<scriptTask id="parseItems" name="Parse Items" scriptFormat="super-js">
<script><![CDATA[
var raw = execution.getVariable('expenseLines');
var lines = raw.split(/[;\n]/);
var items = [];
var total = 0;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (line === '') continue;
var commaIdx = line.lastIndexOf(',');
var itemName;
var amount;
if (commaIdx > 0) {
itemName = line.substring(0, commaIdx).trim();
amount = parseFloat(line.substring(commaIdx + 1).trim());
if (isNaN(amount)) { itemName = line; amount = 0; }
} else {
itemName = line;
amount = 0;
}
items.push({"item": itemName, "amount": amount});
total += amount;
}
var jsonData = JSON.stringify(items);
execution.setVariable('expenseData', jsonData);
execution.setVariable('expenseTotal', wf.formatCurrency(total, '$'));
]]></script>
</scriptTask>
Important: Use JavaScript arrays and objects (
[],{}), NOT Java collections (java.util.ArrayList,java.util.HashMap). GraalJS'sJSON.stringify()cannot serialize Java objects β they produce empty{}.
Step 3: Display with the list Type Hint
The parsed JSON array feeds directly into the list type hint β the same one used by Variable Aggregation:
<userTask id="reviewItems" name="Review Expenses" flowable:candidateGroups="ROLE_MANAGER">
<extensionElements>
<activiti:formProperty id="expenseData" name="Expense Items" type="string" writable="false">
<activiti:value id="type" name="list"/>
</activiti:formProperty>
<activiti:formProperty id="expenseTotal" name="Total Amount" type="string" writable="false">
<activiti:value id="type" name="label"/>
</activiti:formProperty>
</extensionElements>
</userTask>
Result (3 items entered):
Expense Items (1) β Taxi to airport β’ 25
Expense Items (2) β Hotel 2 nights β’ 300
Expense Items (3) β Client dinner β’ 85
Total Amount β $410.00
Optional: Generate a PDF Report
The parsed JSON also works with wf.toHtmlTableWithHeaders() and ${generatePdf}:
<scriptTask id="buildHtml" name="Build Report HTML" scriptFormat="super-js">
<script>
var jsonData = execution.getVariable('expenseData');
execution.setVariable('tableHtml',
wf.toHtmlTableWithHeaders(jsonData, 'item,amount', 'Expense Item,Amount ($)'));
</script>
</scriptTask>
<serviceTask id="genPdf" flowable:delegateExpression="${generatePdf}">
<extensionElements>
<flowable:field name="outputVariable" stringValue="expenseReport"/>
<flowable:field name="template">
<flowable:string><![CDATA[
<html><body>
<h1>Expense Report</h1>
${tableHtml}
<p><strong>Total: ${expenseTotal}</strong></p>
</body></html>
]]></flowable:string>
</flowable:field>
</extensionElements>
</serviceTask>
Comparison: Variable Aggregation vs Textarea Pattern
| Variable Aggregation | Textarea Pattern | |
|---|---|---|
| Data source | Multiple users, one item each | One user, multiple items |
| BPMN mechanism | Multi-instance + variableAggregation |
Textarea + Script Task |
| Output format | JSON array (automatic) | JSON array (Script Task builds it) |
| Display | list type hint |
list type hint (same!) |
| PDF support | wf.toHtmlTable*() |
wf.toHtmlTable*() (same!) |
| New features needed | None | None |
Both patterns produce the same JSON array format, are consumed by the same list type hint, and work with the same wf.* helpers. The only difference is who provides the data.
Reference implementation: See the
expense_report_dynamic.bpmnprocess for a complete working example with parsing, list display, manager approval/rejection/revision loop, and PDF generation.