Table of Contents

Step Handlers

A step handler is a class that performs the work for one step in a flow. Each handler is registered by name, and the manifest references that name in StepMetadata.Type.

The IStepHandler Contract

public interface IStepHandler<TInput>
{
    ValueTask<object?> ExecuteAsync(
        IExecutionContext ctx,
        IFlowDefinition flow,
        IStepInstance<TInput> step);
}

The method receives:

Parameter Type Description
ctx IExecutionContext Run-scoped context: RunId, PrincipalId, TriggerData, TriggerHeaders, JobId
flow IFlowDefinition The flow definition being executed
step IStepInstance<TInput> The deserialized step inputs and metadata

Return object? — whatever you return is serialized to JSON and stored as the step output.

Defining an Input Class

Input properties map directly to keys in StepMetadata.Inputs. FlowOrchestrator deserializes the resolved inputs (after expression evaluation) into your input class before calling ExecuteAsync.

public sealed class SendEmailInput
{
    public string? To { get; set; }
    public string? Subject { get; set; }
    public object? Body { get; set; }  // object? for @triggerBody() expressions
}
Note

Properties that receive @triggerBody() expressions should be typed as object? because the resolved value is a JsonElement. Use ToString() or a helper to normalise.

Minimal Handler Example

public sealed class LogMessageHandler : IStepHandler<LogMessageInput>
{
    private readonly ILogger<LogMessageHandler> _logger;

    public LogMessageHandler(ILogger<LogMessageHandler> logger) => _logger = logger;

    public ValueTask<object?> ExecuteAsync(
        IExecutionContext ctx,
        IFlowDefinition flow,
        IStepInstance<LogMessageInput> step)
    {
        var msg = step.Inputs.Message?.ToString() ?? step.Key;

        _logger.LogInformation(
            "[Flow {RunId}] {Step}: {Message}",
            ctx.RunId, step.Key, msg);

        return ValueTask.FromResult<object?>(new { Logged = msg });
    }
}

public sealed class LogMessageInput
{
    public object? Message { get; set; }
}

Registration

Register handlers in Program.cs or a startup extension. The string name must match StepMetadata.Type exactly.

builder.Services.AddStepHandler<LogMessageHandler>("LogMessage");
builder.Services.AddStepHandler<SendEmailHandler>("SendEmail");
builder.Services.AddStepHandler<QueryDatabaseHandler>("QueryDatabase");

Handlers are resolved from DI per-job execution, so they can receive constructor-injected services (ILogger, HttpClient, DbConnectionFactory, etc.).

Returning Typed Output

Return a plain object or a StepResult<T> to explicitly control status and downstream availability:

// Plain object — status is inferred as Succeeded
return new { OrderId = orderId, Status = "Validated" };

// Explicit StepResult — gives you control over Key, Status, and failure reason
return new StepResult<OrderResult>
{
    Key = step.Key,
    Value = new OrderResult { OrderId = orderId, Approved = true }
};

// Explicit failure
return new StepResult<OrderResult>
{
    Key = step.Key,
    Status = StepStatus.Failed,
    FailedReason = $"Order {orderId} rejected: insufficient inventory"
};

Reading Upstream Outputs

Downstream handlers can read outputs from any previous step in the same run using IOutputsRepository:

public sealed class SaveResultHandler : IStepHandler<SaveResultInput>
{
    private readonly IOutputsRepository _outputs;

    public SaveResultHandler(IOutputsRepository outputs) => _outputs = outputs;

    public async ValueTask<object?> ExecuteAsync(
        IExecutionContext ctx,
        IFlowDefinition flow,
        IStepInstance<SaveResultInput> step)
    {
        // Read the output of a previous step by its step key, deserialised to a CLR type
        var orders = await _outputs.GetStepOutputAsync<List<Order>>(
            ctx.RunId, step.Inputs.FetchStepKey);

        if (orders is null)
            throw new InvalidOperationException("Upstream step output not found.");

        // ... save logic
        return new { Saved = orders?.Count ?? 0 };
    }
}

(The non-generic GetStepOutputAsync(runId, stepKey) returns object? holding a boxed JsonElement; the generic extension in OutputsRepositoryTypedExtensions does the conversion for you.)

Accessing Execution Context from DI

If your handler calls a service that needs the run context (e.g., an audit logger), inject IExecutionContextAccessor:

public sealed class AuditLogger
{
    private readonly IExecutionContextAccessor _accessor;

    public AuditLogger(IExecutionContextAccessor accessor) => _accessor = accessor;

    public void Log(string action)
    {
        var runId = _accessor.CurrentContext?.RunId;
        // ...
    }
}

Cancellation Semantics

Cancellation is a run-level latch checked by the engine at step boundaries, not a token handed to your handler. IExecutionContext carries no CancellationToken, and IStepHandler.ExecuteAsync receives no token parameter.

When a run is cancelled (or times out), RunStepAsync resolves the run-control state before invoking the handler. If the run is Cancelled or TimedOut, the engine records that step as Skipped, completes the run with the terminal status, and returns without calling the handler at all.

The consequence: a handler that is already executing runs to completion. Cancellation takes effect at the next step boundary. If a handler performs long-running work that must be interruptible, give it its own timeout (e.g. an HttpClient.Timeout or a CancellationTokenSource you create inside the handler) rather than relying on the engine.

Polling Handlers

For steps that need to wait for an external system, use PollableStepHandler<T> instead of IStepHandler<T> directly. See Polling Steps.