Portal Community

Execution Model

When the workflow engine reaches a standard node, it resolves the executor from the DI container using a scoped lifetime, calls ExecuteAsync, waits for it to complete, stores the result in the execution context, then disposes the executor. There is no persistent state between invocations.


WorkflowEngine (per-node step)
    │
    ├── Resolve IProcessElementExecution from DI (scoped)
    │
    ├── await executor.ExecuteAsync(ctx, ct)
    │       └── performs operation, returns NodeExecutionResult
    │
    ├── Store result in NodeExecutionContext.OutputStore
    │
    └── Dispose executor (end of DI scope)
  

The Executor Interface

public interface IProcessElementExecution
{
    Task<NodeExecutionResult> ExecuteAsync(
        NodeExecutionContext ctx,
        CancellationToken ct);
}

// BaseNodeExecutor provides settings deserialization:
public abstract class BaseNodeExecutor<TSettings> : IProcessElementExecution
    where TSettings : class, new()
{
    public async Task<NodeExecutionResult> ExecuteAsync(
        NodeExecutionContext ctx,
        CancellationToken ct)
    {
        var settings = ctx.Node.DeserializeSettings<TSettings>();
        return await ExecuteAsync(ctx, settings, ct);
    }

    protected abstract Task<NodeExecutionResult> ExecuteAsync(
        NodeExecutionContext ctx,
        TSettings settings,
        CancellationToken ct);
}

Characteristics of Standard Nodes

CharacteristicDetail
DI lifetimeScoped — new instance per workflow node execution
StateNone carried between executions — all state comes from NodeExecutionContext
ConcurrencyInherently safe — each execution is isolated
Startup costNear-zero — DI resolution plus constructor injection only
TestingSimple unit testing — construct executor, call ExecuteAsync, assert result
Failure isolationA crashed executor affects only that execution — the host process continues

Example: A Typical Standard Node Executor

public class HttpCallExecutor : BaseNodeExecutor<HttpCallSettings>
{
    private readonly IHttpClientFactory _http;

    public HttpCallExecutor(IHttpClientFactory http) { _http = http; }

    protected override async Task<NodeExecutionResult> ExecuteAsync(
        NodeExecutionContext ctx,
        HttpCallSettings settings,
        CancellationToken ct)
    {
        var client = _http.CreateClient();
        var response = await client.GetAsync(settings.Url, ct);
        var body = await response.Content.ReadAsStringAsync(ct);

        return NodeExecutionResult.Success(new
        {
            statusCode = (int)response.StatusCode,
            body       = body,
            headers    = response.Headers.ToDictionary(h => h.Key, h => h.Value.First())
        });
    }
}
HttpClientFactory is scoped-safe: IHttpClientFactory is singleton and manages connection pooling internally. Injecting it into a scoped executor is correct — you get a fresh HttpClient instance each time, but the underlying connections are pooled at the factory level.