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"

When the expression resolves to null or an empty array, the loop completes as Succeeded with zero iterations. Downstream steps (those with RunAfter = ["process_orders"]) still run.

ConcurrencyLimit

Value Behaviour
1 Iterations run one at a time (sequential)
N > 1 Up to N iterations run simultaneously; remaining items wait for a slot
0 (or omit) Defaults to 1 (sequential)

With ConcurrencyLimit = 2 and 4 items:

Iteration 0  ──► validate_order  (slot 1)
Iteration 1  ──► validate_order  (slot 2)
Iteration 2  ──► (waits for slot)
Iteration 3  ──► (waits for slot)

When iteration 0 completes, iteration 2 starts. When iteration 1 completes, iteration 3 starts.

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++)
{
    var output = await outputs.GetStepOutputAsync(runId, $"process_orders.{i}.validate_order");
    // output is JsonElement?
}

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).

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. Declare them as properties in your input class:

public sealed class ValidateOrderInput
{
    // Static manifest input
    public decimal MaxValue { get; set; }

    // Injected per iteration
    public object? LoopItem { get; set; }
    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
                        }
                    }
                }
            },

            // 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.