Portal Community

What Makes This a "Demo" Handler

The built-in handler writes to a generic log table (Process_StateLogs) that works for any use case. The SalesLeads handler writes directly to CRMSimple_SalesLeads — a real CRM domain table. The node that uses this handler doesn't just track state; it owns and manages the lead record.

This is the template to study before building your own handler. Every key decision is made here: key mapping, update strategy, plugin registration, 3-project layout.

CRMSimple_SalesLeads Table Schema

Column Type Notes
ID int Identity PK
Email nvarchar(320) Maps to ItemEntityKey — the primary entity identifier
LeadType nvarchar(100) Maps to ItemKey — the processing discriminator (e.g. "newsletter", "demo-request")
TenantID int Multi-tenant isolation
Status int FK → Process_StateLogIdempotencyStatuses (reuses same lookup)
FirstName nvarchar(200) Lead name data
LastName nvarchar(200) Lead name data
Company nvarchar(500) Lead company
Source nvarchar(200) Lead source (e.g. "linkedin", "webinar")
Notes nvarchar(max) Enrichment notes
SupersededByID int (null) FK self-ref — if in-place is not desired
Created datetime2 Row creation
Modified datetime2 Last update
Deleted bit Soft delete

Key Mapping Strategy

The SalesLeads handler maps business keys to StateHandlerContext fields differently from the built-in handler:

StateHandlerContext Field Maps To Example Value
ItemEntityKey Email (the lead's email address) "jane@acme.com"
ItemKey LeadType (the lead category) "demo-request"
TenantID TenantID 42
SubItemKey (unused — available for sub-categorisation) ""
// SalesLeadRepository — key mapping in queries
public async Task<IdempotencyStatus?> GetCurrentStatusAsync(
    StateHandlerContext ctx, CancellationToken ct)
{
    var lead = await _ctx.SalesLeads
        .Where(l => l.Email    == ctx.ItemEntityKey   // Email ← ItemEntityKey
                 && l.LeadType == ctx.ItemKey          // LeadType ← ItemKey
                 && l.TenantID == ctx.TenantID
                 && !l.Deleted)
        .OrderByDescending(l => l.Created)
        .FirstOrDefaultAsync(ct);

    return lead is null
        ? null
        : (IdempotencyStatus)lead.Status;
}

In-Place Update vs Append-Only

The built-in handler uses an append-only strategy — it inserts a new row and marks old rows Superseded using SupersededByID. This gives complete history but uses more storage.

The SalesLeads handler uses in-place update — it updates the existing lead row's Status, data fields, and Modified timestamp. This is appropriate when the entity IS the record (a CRM lead record should be one row, not dozens of history rows).

Strategy Built-In Handler SalesLeads Handler
How records grow Inserts new row each update Updates existing row in place
History Full append-only trail Modified timestamp only
Storage More rows over time One row per lead+type
Best For Audit-heavy use cases Domain entity ownership
public async Task SetAsync(
    StateHandlerContext ctx, IdempotencyStatus status, string? data, CancellationToken ct)
{
    var lead = await _repo.GetByKeyAsync(
        email: ctx.ItemEntityKey, leadType: ctx.ItemKey, ctx.TenantID, ct);

    if (lead is null)
    {
        // First time we see this lead — insert
        await _repo.InsertAsync(BuildLead(ctx, status, data), ct);
    }
    else
    {
        // Lead exists — update in place
        lead.Status   = (int)status;
        lead.Modified = DateTime.UtcNow;
        // Optionally update enrichment fields from data JSON
        await _repo.UpdateAsync(lead, ct);
    }
}

Plugin Registration

public class SalesLeadsDataStateMachinePlugin : IDataStateMachinePlugin
{
    public void Register(IServiceCollection services, IConfiguration config)
    {
        // Handler is Singleton — shared across all requests
        services.AddSingleton<IDataStateMachineHandler, SalesLeadsDataStateMachineHandler>();

        // Repository is Scoped — one per request (holds DbContext)
        services.AddScoped<ISalesLeadRepository, SalesLeadRepository>();

        // Register the DbContext for this handler
        services.AddDbContext<SalesLeadsDbContext>(opts =>
            opts.UseSqlServer(config.GetConnectionString("BizFirstFiV3")));
    }
}

Node Config to Activate This Handler

{
  "dataStateMachine": {
    "handler": "sales-leads",
    "scope":   "Element",
    "options": {}
  }
}

Using as a Template for Your Custom Handler

Replace CRMSimple_SalesLeads with your own domain table. Replace Email and LeadType with your own business keys. Decide on in-place update vs append-only. Implement IDataStateMachinePlugin. Add your DLL to the output folder. Done.

The SalesLeads handler's ISalesLeadRepository interface is intentionally minimal: GetByKeyAsync, InsertAsync, UpdateAsync. This is the right scope for a handler repository — avoid adding query methods that belong in a separate read service.