Portal Community

The Template Method

// BaseNodeExecutor.cs (simplified)
public abstract partial class BaseNodeExecutor : IProcessElementExecution
{
    // Called by the engine — runs the full pipeline
    public async Task<NodeExecutionResult> ExecuteAsync(
        NodeExecutionContext nodeExecutionContext,
        CancellationToken cancellationToken = default)
    {
        // Validates context, runs all pipeline stages
        return await Execute(nodeExecutionContext, cancellationToken);
    }

    // THE METHOD YOU IMPLEMENT
    protected abstract Task<NodeExecutionResult> ExecuteInternalAsync(
        NodeExecutionContext nodeExecutionContext,
        CancellationToken cancellationToken);
}

Minimal Complete Executor

// MyCustomNodeExecutor.cs
using BizFirst.Ai.ProcessExecutor.Service;  // BaseNodeExecutor
using BizFirst.Ai.ProcessEngine.Domain.Execution;
using BizFirst.Ai.ProcessEngine.Domain.Execution.Context;

public partial class MyCustomNodeExecutor : BaseNodeExecutor, IActionNodeExecution
{
    public const string NodeTypeName = "my-custom-node";
    public override string ProcessElementTypeCode => NodeTypeName;

    private readonly IMyExternalService _myService;

    public MyCustomNodeExecutor(
        IMyExternalService myService,
        ILogger<MyCustomNodeExecutor> logger,
        INodeConfigurationParser? configParser = null)
        : base(logger, configParser)
    {
        _myService = myService;
    }

    protected override async Task<NodeExecutionResult> ExecuteInternalAsync(
        NodeExecutionContext ctx,
        CancellationToken cancellationToken)
    {
        // 1. Get settings (populated by BaseNodeExecutor before this is called)
        var s = (MyCustomNodeSettings)settings!;

        // 2. Do the work
        var result = await _myService.DoWorkAsync(s.InputValue, cancellationToken);

        // 3. Return success result with output data
        return new NodeExecutionResult
        {
            IsSuccess     = true,
            OutputPortKey = "main",
            OutputData    = new Dictionary<string, object>
            {
                { "result", result },
                { "status", "ok"   }
            }
        };
    }
}

// MyCustomNodeExecutor.Config.cs
public partial class MyCustomNodeExecutor
{
    private MyCustomNodeSettings? mySettings => settings as MyCustomNodeSettings;

    public override BaseNodeExecutorSettings CreateExecutorSettings()
        => new MyCustomNodeSettings();
}

What BaseNodeExecutor Provides

CapabilityPartial Class File
Configuration loading and validationBaseNodeExecutor.Config.cs
Pre/Post GuardRails executionBaseNodeExecutor.PreGuardRails.cs / .PostGuardRails.cs
Error routing (error output port)BaseNodeExecutor.ErrorHandler.cs
HIL suspension/resumeBaseNodeExecutor.Suspension.cs
Webhook capabilityBaseNodeExecutor.Webhooks.cs
Form capabilityBaseNodeExecutor.Forms.cs
SignalR event emissionBaseNodeExecutor.Event.cs
Performance metrics and tracingBaseNodeExecutor.Activity.cs / .ProgressReport.cs
Item iteration supportBaseNodeExecutor.Iteration.cs

Constructor Base Call

The base constructor requires a logger and optionally a config parser and form resolver. Always pass through all parameters received from DI:

public MyCustomNodeExecutor(
    IMyService service,
    ILogger<MyCustomNodeExecutor> logger,
    INodeConfigurationParser? configParser = null,
    NodeFormResolver? formResolver = null)
    : base(logger, configParser, formResolver)
{
    _service = service;
}
settings Is Populated Before ExecuteInternalAsync The settings field on BaseNodeExecutor is populated during the EntryValidate pipeline stage — before ExecuteInternalAsync is called. You can cast and use it directly in ExecuteInternalAsync without calling LoadConfigAsync yourself.