SalesLeads Demo Handler
The SalesLeads handler is the canonical template for building a custom DataStateMachine handler. It demonstrates how to bind a node to a real CRM table, map business keys to ItemKey fields, and choose between in-place update and append-only strategies.
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.