Table of Contents

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.