ForEach Loops
The ForEach step type fans out over a collection, executing a child step graph for each item. Iterations can run sequentially or in parallel with a configurable concurrency limit.
LoopStepMetadata
Use LoopStepMetadata instead of StepMetadata to declare a loop:
["process_orders"] = new LoopStepMetadata
{
Type = "ForEach", // always "ForEach" — resolved to the built-in handler
RunAfter = new RunAfterCollection
{
["prepare"] = [StepStatus.Succeeded]
},
// Source collection — literal or expression
ForEach = "@triggerBody()?.orderIds",
// Maximum iterations running at the same time
// 1 = sequential, >1 = parallel fan-out
ConcurrencyLimit = 2,
// Steps executed once per item
Steps = new StepCollection
{
["validate_order"] = new StepMetadata
{
Type = "ValidateOrder",
Inputs = new Dictionary<string, object?>
{
["maxValue"] = 10000 // static — same for every iteration
}
}
}
}
Collection Sources
ForEach accepts either a static array or an expression:
// Static array
ForEach = new[] { "ORD-001", "ORD-002", "ORD-003" }
// Expression resolved from trigger payload at execution time
ForEach = "@triggerBody()?.orderIds"
@triggerHeaders() and @triggerHeaders()['X-Batch-Ids'] are also accepted as sources.
Warning
@steps('key').output… is not supported as a ForEach source — only trigger-body and trigger-header expressions are resolved. A step-output source is passed through as a literal string and the loop completes with zero iterations.
When the expression resolves to null or an empty array, the loop completes as Succeeded with zero iterations. Downstream steps (those declaring RunAfter = new RunAfterCollection { ["process_orders"] = [StepStatus.Succeeded] }) still run.
ConcurrencyLimit
| Value | Behaviour |
|---|---|
1 |
Strictly sequential — iteration n+1 starts only once every step of iteration n reached a terminal status |
N > 1 |
At most N iterations in flight at once; each one that finishes frees a slot for the next |
0 (or omit) |
Defaults to 1 |
ConcurrencyLimit is a real running-slot bound, not a dispatch-time stagger. The loop step fans out only the first window of iterations; every later one is admitted from the DAG continuation as an earlier iteration settles. An iteration counts as finished only when its whole body is terminal — so a body that parks on a WaitForSignal or a polling step holds its slot for as long as it is parked.
With ConcurrencyLimit = 2 and 4 items:
Iteration 0 ──► validate_order ──► … ──► done ─┐
Iteration 1 ──► validate_order ──► … ──► done ─┤
├─► Iteration 2 admitted when 0 or 1 finishes
└─► Iteration 3 admitted when the next slot frees
Steps downstream of the loop wait for every iteration — see Loop Completion and Downstream Ordering.
Note
Before v1.31.4 ConcurrencyLimit was implemented as a bucketIndex × 100 ms dispatch delay, which bounded nothing once a body parked past that delay: a loop declaring ConcurrencyLimit = 1 still ran every iteration concurrently (issue #181). Manifests relying on the old fan-out-everything behaviour should raise the limit to the item count.
Child Step Key Format
Each child step gets a runtime key in the format {parentKey}.{index}.{childKey}:
process_orders.0.validate_order
process_orders.1.validate_order
process_orders.2.validate_order
These keys appear in the dashboard run timeline and can be used with IOutputsRepository to read per-iteration outputs:
for (int i = 0; i < itemCount; i++)
{
// Untyped overload — returns object? (typically a JsonElement at runtime)
var output = await outputs.GetStepOutputAsync(runId, $"process_orders.{i}.validate_order");
// Typed overload — deserialises straight to your output contract
var typed = await outputs.GetStepOutputAsync<ProcessOrderItemOutput>(runId, $"process_orders.{i}.validate_order");
}
Referencing a Sibling Step's Output
A child step can read the output of another child in the same iteration using a
plain @steps('siblingKey') expression — the bare key, exactly as it is written in the
manifest. At runtime the resolver rewrites it to the current iteration's scope, so a
step running as process_orders.2.archive_order resolves @steps('validate_order') to
process_orders.2.validate_order — never iteration 0's:
Steps = new StepCollection
{
["validate_order"] = new StepMetadata
{
Type = "ProcessOrderItem",
Inputs = new Dictionary<string, object?> { ["maxOrderValue"] = 10000 }
},
["archive_order"] = new StepMetadata
{
Type = "LogMessage",
RunAfter = new RunAfterCollection { ["validate_order"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
// Bare sibling key — resolves to THIS iteration's validate_order output.
["message"] = "@steps('validate_order').output.note"
}
}
}
Resolution rules for @steps('key') evaluated from inside a loop:
| Key written in the expression | Resolves to |
|---|---|
A bare sibling key (validate_order) |
The current iteration's scope: {loop}.{index}.validate_order |
A bare key that is a top-level step (prepare_batch) |
The top-level step, unchanged — the loop scope is not prepended |
An explicitly-qualified key (process_orders.0.validate_order) |
Used verbatim (hard-codes iteration 0) |
The bare-key rewrite only applies when the key is not a top-level manifest step, so
upstream references made from inside a loop keep working. The same rule governs
.status and .error access, and it mirrors the sibling-key handling that RunAfter
already applies within a loop scope.
Note
The rewrite walks enclosing loop scopes nearest-first, so a deeply nested child can
reference a sibling declared in the same loop. It does not reach into a different
iteration — use the explicit {loop}.{index}.{child} key for cross-iteration reads.
How Dispatch Works
ForEachStepHandler does not enqueue jobs directly. Instead it returns a StepResult that carries a DispatchHint with Spawn entries — one per iteration. FlowOrchestratorEngine receives the hint, validates that the spawned step keys are not already present in the static DAG, and dispatches each one via IStepDispatcher. This keeps runtime dispatch logic in the engine and makes ForEachStepHandler portable across all runtime adapters (Hangfire, InMemory, or any future adapter).
Loop Completion and Downstream Ordering
A loop step that fanned out reports Running, not Succeeded. It is settled as Succeeded only once every step of every iteration has reached a terminal status (Succeeded, Failed, or Skipped) — the loop barrier. A step declaring RunAfter = { <loop>: [Succeeded] } therefore runs strictly after the whole loop body:
scan_start ─► scan_process ─┬─► [0] wait_robot_goto ─► open_camera ─┐
└─► [1] wait_robot_goto ─► open_camera ─┴─► robot_callback_success
| Loop state | Loop step status | Downstream step |
|---|---|---|
| Fanned out, iterations in flight | Running |
Waiting |
| Every iteration terminal | Succeeded |
Ready — dispatched now |
| Zero items, or a loop body with no steps | Succeeded immediately |
Ready immediately |
Note
A failing iteration does not fail the loop: a child that failed and a child skipped because
its RunAfter could not be satisfied both count as terminal, so the barrier settles as
Succeeded and the downstream step still runs. Gate on the individual iteration outputs
when a downstream step must react to a partial failure.
Important
Before v1.30.1 the loop step reported Succeeded the moment it enqueued its children, so
the downstream step ran in parallel with the iterations. With fast children this was an
invisible race; with a parked child (WaitForSignal, a polling step) the downstream step ran
first — issue #169. If a flow
relied on the old fire-and-forget timing, move that work into the loop body.
Per-Iteration Injected Inputs
ForEachStepHandler injects two additional inputs into each child step before executing it:
| Key | Value | Description |
|---|---|---|
__loopItem |
The current item from the collection | The item value ("ORD-001", a number, or a JSON object) |
__loopIndex |
Zero-based position | 0, 1, 2, ... |
These are merged with the static Inputs defined in the manifest. Bind them with an explicit [JsonPropertyName] attribute — the double-underscore keys do not bind by naming convention, so a plain LoopItem property silently stays null:
public sealed class ValidateOrderInput
{
// Static manifest input
public decimal MaxValue { get; set; }
// Injected per iteration — the JsonPropertyName attribute is REQUIRED
[JsonPropertyName("__loopItem")]
public object? LoopItem { get; set; }
[JsonPropertyName("__loopIndex")]
public int LoopIndex { get; set; }
}
Note
The injection keys are __loopItem (double-underscore prefix). They will not collide with user-defined input keys as long as those don't start with __.
Full Example: OrderBatchFlow
public sealed class OrderBatchFlow : IFlowDefinition
{
public Guid Id { get; } = new Guid("00000000-0000-0000-0000-000000000005");
public string Version => "1.0";
public FlowManifest Manifest { get; set; } = new FlowManifest
{
Triggers = new FlowTriggerCollection
{
["manual"] = new TriggerMetadata { Type = TriggerType.Manual },
["webhook"] = new TriggerMetadata
{
Type = TriggerType.Webhook,
Inputs = new Dictionary<string, object?>
{
["webhookSlug"] = "order-batch"
}
}
},
Steps = new StepCollection
{
// Entry step: logs batch ID from trigger
["prepare_batch"] = new StepMetadata
{
Type = "LogMessage",
Inputs = new Dictionary<string, object?>
{
["message"] = "@triggerBody()?.batchId"
}
},
// ForEach loop over orderIds from trigger payload
["process_orders"] = new LoopStepMetadata
{
Type = "ForEach",
RunAfter = new RunAfterCollection { ["prepare_batch"] = [StepStatus.Succeeded] },
ForEach = "@triggerBody()?.orderIds",
ConcurrencyLimit = 2,
Steps = new StepCollection
{
["validate_order"] = new StepMetadata
{
Type = "ProcessOrderItem",
Inputs = new Dictionary<string, object?>
{
["maxOrderValue"] = 10000 // same for every iteration
}
},
// Reads the sibling validate_order output for THIS iteration.
["archive_order"] = new StepMetadata
{
Type = "LogMessage",
RunAfter = new RunAfterCollection { ["validate_order"] = [StepStatus.Succeeded] },
Inputs = new Dictionary<string, object?>
{
["message"] = "@steps('validate_order').output.note"
}
}
}
},
// Runs after all iterations complete
["finalize_batch"] = new StepMetadata
{
Type = "LogMessage",
RunAfter = new RunAfterCollection
{
["process_orders"] = [StepStatus.Succeeded]
},
Inputs = new Dictionary<string, object?>
{
["message"] = "Order batch processing complete."
}
}
}
};
}
Triggering with a Payload
POST /flows/api/webhook/order-batch
Content-Type: application/json
Idempotency-Key: batch-2026-04-20-001
{
"batchId": "BATCH-001",
"orderIds": ["ORD-001", "ORD-002", "ORD-003", "ORD-004"]
}
The Idempotency-Key header prevents the same batch from being processed twice if the webhook is retried by the sender.
Nested Loops
LoopStepMetadata.Steps supports LoopStepMetadata entries — loops can be nested. Each level produces keys with an additional .{index}.{childKey} segment. Deep nesting (>2 levels) is supported but adds complexity to key-based output queries.