Chd.Workflow
10.0.2
dotnet add package Chd.Workflow --version 10.0.2
NuGet\Install-Package Chd.Workflow -Version 10.0.2
<PackageReference Include="Chd.Workflow" Version="10.0.2" />
<PackageVersion Include="Chd.Workflow" Version="10.0.2" />
<PackageReference Include="Chd.Workflow" />
paket add Chd.Workflow --version 10.0.2
#r "nuget: Chd.Workflow, 10.0.2"
#:package Chd.Workflow@10.0.2
#addin nuget:?package=Chd.Workflow&version=10.0.2
#tool nuget:?package=Chd.Workflow&version=10.0.2
Chd.Workflow
Chd.Workflow is a lightweight workflow engine for ASP.NET Core applications. It helps you model business processes as a tree of nodes and transitions instead of spreading workflow logic across controllers, services, and large if/else blocks.
The package is designed for practical business applications. You define the process structure once, then let the engine manage state movement, validation, transition execution, history, runtime data, and API exposure.
Typical usage areas include:
- approval flows,
- budget and purchase requests,
- leave and HR processes,
- document review,
- onboarding or operational checklists,
- internal admin workflows.
Table of Contents
- Why this package exists
- Main capabilities
- Installation
- Quick start
- Hosting model
- Configuration with
ChdWorkflowOptions - Core domain model
- Workflow actions
- Workflow guards
- Workflow rules
- Participant directory and notifications
- REST API overview
- Dynamic forms and field options
- Database providers and persistence
- Frontend integration
- Migration advice
- Example definition
- Troubleshooting
- License
Why this package exists
Many applications start with a very small process:
- Draft
- Submitted
- Approved
- Rejected
Over time, that process grows:
- some requests need manager approval,
- others need finance approval,
- large amounts need one more step,
- different tenants need different routing,
- each step needs a different form,
- audit and history become mandatory.
When that happens, workflow logic often becomes hard to follow. The state machine is no longer in one place. It is spread across controllers, services, validation code, and database logic.
Chd.Workflow solves that by giving you one structured workflow model:
- nodes describe steps,
- transitions describe possible movements,
- actions contain business side effects,
- guards perform pre-checks,
- rules choose routes,
- the engine stores state and history.
This makes workflows easier to maintain, easier to visualize, and easier to evolve.
Main capabilities
Tree-based definitions
A workflow is built around a RootNode, and every step can contain child nodes.
Dynamic forms
Each node can define its own fields and form behavior.
Transition actions
Transitions can execute backend methods through [WorkflowAction] classes.
Guard support
Transitions can run one or more guards before moving to the next step.
Rule-based routing
A transition can use a workflow rule to decide the target node dynamically.
Runtime instance data
Workflow instances keep runtime data and history.
Built-in API surface
The package can expose minimal API endpoints automatically.
Multi-provider persistence
Use in-memory storage for simple scenarios, or relational storage for real environments.
React companion package
The engine is designed to work very well with qp-workflow-react, which provides a visual designer and runtime UI.
Installation
dotnet add package Chd.Workflow
Current package target in this repository: .NET 8.0.
Quick start
The smallest useful setup is only a few lines.
using Chd.Workflow.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.AddWorkflow();
var app = builder.Build();
await app.UseChdWorkflowAsync();
app.Run();
A common matching configuration looks like this:
{
"DatabaseProvider": "PostgreSQL",
"ConnectionStrings": {
"PostgreSQL": "Host=localhost;Database=workflow;Username=postgres;Password=secret",
"SqlServer": "Server=localhost;Database=Workflow;Trusted_Connection=True;TrustServerCertificate=True"
}
}
What this setup does:
- registers workflow services,
- scans the calling assembly for actions, guards, and rules,
- resolves the storage provider,
- optionally runs packaged migrations,
- maps workflow endpoints under the configured prefix.
Hosting model
The current package uses a single main registration style:
builder.AddWorkflow();
You can also use typed overloads when the host application wants to provide custom participant resolution or custom notification behavior.
Basic host
builder.AddWorkflow(options =>
{
options.RoutePrefix = "api/workflow";
});
Host with participant directory
builder.AddWorkflow<MyParticipantDirectory>(options =>
{
options.AutoMigrate = true;
});
Host with participant directory and notifier
builder.AddWorkflow<MyParticipantDirectory, MyStateChangeNotifier>();
Then complete the startup with:
await app.UseChdWorkflowAsync();
This second call is responsible for startup-time workflow behavior such as migrations and endpoint mapping.
Configuration with ChdWorkflowOptions
ChdWorkflowOptions is the main configuration object for the package.
| Property | Description |
|---|---|
DatabaseProvider |
Optional provider override. Common values: InMemory, SqlServer, PostgreSQL. |
ConnectionString |
Explicit connection string override. |
AutoMigrate |
If true, packaged workflow migrations run during startup. |
MapEndpoints |
If true, workflow minimal APIs are mapped automatically. |
RoutePrefix |
Route prefix for the workflow API. |
WorkflowDatabaseSchema |
Schema used for workflow tables in relational providers. |
WorkflowTableNaming |
Naming convention for workflow tables. |
Example:
builder.AddWorkflow(options =>
{
options.DatabaseProvider = "SqlServer";
options.ConnectionString = builder.Configuration.GetConnectionString("SqlServer");
options.RoutePrefix = "api/workflow";
options.AutoMigrate = true;
});
You can also register host integrations through the options object:
builder.AddWorkflow(options =>
{
options.UseParticipantDirectory<MyParticipantDirectory>();
options.UseStateChangeNotifier<MyNotifier>();
});
Core domain model
The package is centered around a few core models.
WorkflowDefinition
This is the template of a workflow.
Typical data includes:
IdNameDescriptionVersionIsActiveRootNodeTenantId- audit fields such as
CreatedAt,UpdatedAt,CreatedBy
Node
A node represents one step in the process.
Important members commonly include:
IdNameTitleDescriptionTypeChildrenFormTypeFormUrlFormComponentDataQueryFieldsTransitionsRulesAllowedRolesTimeoutTimeoutAction
Transition
A transition describes how a node can move to another node.
Important members include:
ActionLabelTargetNodeIdGuardGuardHandlerGuardHandlersGuardFailureMessageActionHandlerActionConditionActionConditionModeRuleHandlerRuleMappingsButtonStyleRequiresConfirmationRequiresComment
Field
A field defines one form input on a node.
Backend field types include:
TextTextAreaNumberDecimalDateDateTimeTimeCheckboxRadioDropdownMultiSelectFileImageRichTextHidden
WorkflowInstance
This is the runtime state of an active workflow.
It stores data such as:
DefinitionIdDefinitionVersionCurrentNodeIdStatusDataHistoryCreatedAtUpdatedAtCompletedAtTenantId
Workflow actions
Workflow actions are how you connect transitions to business code.
Actions are discovered by assembly scanning and are identified with the [WorkflowAction] attribute.
Example action
using Chd.Workflow.Attributes;
using Chd.Workflow.Interfaces;
[WorkflowAction("approve-leave", DisplayName = "Approve Leave", Category = "HR")]
public sealed class ApproveLeaveAction : IWorkflowAction
{
private readonly ILeaveService _leaveService;
private readonly IEmailService _emailService;
public ApproveLeaveAction(ILeaveService leaveService, IEmailService emailService)
{
_leaveService = leaveService;
_emailService = emailService;
}
public async Task<WorkflowActionResult> ExecuteAsync(WorkflowActionContext ctx)
{
var leaveId = ctx.FormData["leaveId"]?.ToString();
if (string.IsNullOrWhiteSpace(leaveId))
return WorkflowActionResult.Fail("leaveId is required");
await _leaveService.ApproveAsync(leaveId, ctx.UserId, ctx.CancellationToken);
await _emailService.SendApprovalNotificationAsync(leaveId);
return WorkflowActionResult.Ok(new
{
approvedAt = DateTime.UtcNow,
approvedBy = ctx.UserId
});
}
}
Registration behavior
In many applications, you do not need to register actions manually. Calling builder.AddWorkflow(...) is enough, as long as your action classes are in the scanned assembly or in a referenced assembly that is part of the registration flow.
When to use actions
Actions are a good place for:
- updating domain data,
- calling services,
- sending notifications,
- saving external audit entries,
- publishing output values for later workflow conditions.
WorkflowActionContext
The action context gives access to runtime information such as:
- instance,
- definition,
- current node,
- target node,
- selected transition,
- form data,
- user id,
- user roles,
- comment,
- service provider,
- cancellation token.
Bind an action to a transition
{
"action": "approve",
"label": "Approve",
"targetNodeId": "approved",
"actionHandler": "approve-leave"
}
Legacy controller-style actions
If you are migrating an older project, you may want to keep an existing controller or service method close to its current shape instead of creating a separate wrapper class immediately.
For this case, the package also supports [WorkflowLegacyAction].
This option is useful when:
- you already have stable request and response DTOs,
- you want minimum source changes,
- you want to annotate an existing method instead of writing a new
IWorkflowActionclass, - you are migrating a legacy codebase gradually.
Supported method patterns include:
Task<TDto> Method(RequestDto request)Task<ActionResult<TDto>> Method(RequestDto request)Task<IActionResult> Method(RequestDto request)WorkflowActionResult Method(...)- optional special parameters such as
CancellationToken,IServiceProvider, andWorkflowActionContext
Example:
using Chd.Workflow.Attributes;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class LegacyBudgetController : ControllerBase
{
private readonly IBudgetService _budgetService;
public LegacyBudgetController(IBudgetService budgetService)
{
_budgetService = budgetService;
}
[HttpPost("create")]
[WorkflowLegacyAction(
"legacy-budget-create",
DisplayName = "Legacy Budget Create",
Category = "Legacy Budget",
InputType = typeof(BudgetCreateRequest),
OutputType = typeof(BudgetCreateResponse))]
public async Task<ActionResult<BudgetCreateResponse>> Create(
[FromBody] BudgetCreateRequest request,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var response = await _budgetService.CreateAsync(request);
if (!response.Success)
return BadRequest(response);
return Ok(response);
}
}
At runtime, the workflow engine will:
- bind workflow form data into the request DTO,
- create the controller or service through dependency injection,
- call the annotated method,
- convert DTO or MVC-style return values into
WorkflowActionResultautomatically.
This gives you a lower-friction migration path while keeping the modern IWorkflowAction model available for greenfield code.
Workflow guards
Guards run before the transition completes. They are intended for checks, validation, decision support, and safe routing support.
Typical guard use cases:
- limit checks,
- eligibility checks,
- warning scenarios,
- transition denial with a business reason,
- dynamic rerouting.
Example guard
[WorkflowGuard("amount-check", DisplayName = "Amount Check", Category = "Budget")]
public sealed class AmountCheckGuard : IWorkflowGuard
{
public Task<WorkflowGuardResult> EvaluateAsync(WorkflowGuardContext ctx)
{
var amount = Convert.ToDecimal(ctx.AllData["amount"] ?? 0m);
if (amount > 100_000m)
return Task.FromResult(WorkflowGuardResult.Deny("Amount is above the allowed limit."));
if (amount > 10_000m)
return Task.FromResult(WorkflowGuardResult.Warn("High amount. Please confirm before continuing."));
return Task.FromResult(WorkflowGuardResult.Allowed(new { limitExceeded = false }));
}
}
Supported transition guard patterns
A transition can use:
GuardHandlerfor one guard,GuardHandlersfor multiple guards,Guardfor an expression evaluated on workflow data.
When a guard denies the transition, the API returns a 400 response with code = "GUARD_FAILED".
Workflow rules
Rules are useful when the next step depends on a computed string result.
For example, a rule might return:
lowmediumhighfinancemanager
Example rule
[WorkflowRule("amount-band", DisplayName = "Amount Band", Category = "Routing", PossibleResults = new[] { "low", "medium", "high" })]
public sealed class AmountBandRule : IWorkflowRule
{
public Task<WorkflowRuleResult> EvaluateAsync(WorkflowRuleContext ctx)
{
var amount = Convert.ToDecimal(ctx.AllData["amount"] ?? 0m);
var result = amount > 100_000m
? "high"
: amount > 10_000m
? "medium"
: "low";
return Task.FromResult(WorkflowRuleResult.Ok(result));
}
}
Transition example with rule mappings
{
"action": "submit",
"label": "Submit",
"targetNodeId": "manager-review",
"ruleHandler": "amount-band",
"ruleMappings": [
{ "ruleResult": "high", "targetNodeId": "gm-review" },
{ "ruleResult": "medium", "targetNodeId": "manager-review" },
{ "ruleResult": "low", "targetNodeId": "auto-approved" }
]
}
If no mapping matches, TargetNodeId stays as the fallback route.
Participant directory and notifications
The engine can work together with the host for user/group resolution and notification handling.
IWorkflowParticipantDirectory
This interface is the bridge between the workflow engine and your own identity or role data source.
It is useful for:
- resolving node roles to users,
- feeding designer pickers,
- preparing recipients for notifications.
Main method:
Task<IReadOnlyList<WorkflowUser>> ResolveUsersInGroupsAsync(
IReadOnlyList<string> groupOrRoleNames,
CancellationToken cancellationToken = default);
There is also a convenience base class:
public abstract class WorkflowParticipantDirectoryBase : IWorkflowParticipantDirectory
Example participant directory
public sealed class HostParticipantDirectory : WorkflowParticipantDirectoryBase
{
private readonly AppDbContext _db;
public HostParticipantDirectory(AppDbContext db)
{
_db = db;
}
public override async Task<IReadOnlyList<WorkflowUser>> ResolveUsersInGroupsAsync(
IReadOnlyList<string> groupOrRoleNames,
CancellationToken cancellationToken = default)
{
return await _db.UserRoles
.Where(x => groupOrRoleNames.Contains(x.RoleName))
.Select(x => new WorkflowUser
{
Id = x.UserId,
Email = x.Email,
DisplayName = x.DisplayName
})
.ToListAsync(cancellationToken);
}
}
IWorkflowStateChangeNotifier
This interface lets the host react to successful transitions.
Typical uses include:
- sending email,
- sending SMS,
- writing integration logs,
- pushing notifications to other systems.
Notification admin endpoints
The package exposes notification admin endpoints under /admin/notification-settings when the host has the required notification settings service registered.
REST API overview
By default, endpoints are exposed under:
/api/workflow
Definitions
| Method | Endpoint | Description |
|---|---|---|
| GET | /definitions |
List definitions |
| GET | /definitions/{id} |
Get one definition |
| POST | /definitions |
Create a definition |
| PUT | /definitions/{id} |
Update a definition |
| DELETE | /definitions/{id} |
Delete a definition |
Instances
| Method | Endpoint | Description |
|---|---|---|
| GET | /instances/{id} |
Get raw instance data |
| GET | /instances/{id}/state |
Get render-ready workflow state |
| GET | /instances/{id}/field-options/{fieldName} |
Resolve dynamic select options |
| POST | /instances |
Create instance |
| POST | /instances/{id}/transition |
Execute transition |
| POST | /instances/{id}/validate |
Validate values without transition |
| POST | /instances/{id}/cancel |
Cancel instance |
Metadata
| Method | Endpoint | Description |
|---|---|---|
| GET | /actions |
List registered actions |
| GET | /actions/grouped |
List actions grouped by category |
| GET | /guards |
List registered guards |
| GET | /rules |
List registered rules |
| GET | /rules/grouped |
List rules grouped by category |
| GET | /rules/{name} |
Get one rule by name |
Admin
| Method | Endpoint | Description |
|---|---|---|
| GET | /admin/notification-settings |
Read notification settings |
| PUT | /admin/notification-settings |
Save notification settings |
| GET | /admin/participant-groups |
List roles/groups for designer pickers |
Error shape notes
Typical error patterns include:
{ error: "..." }{ error: "...", errors: { fieldName: "message" } }{ code: "GUARD_FAILED", error: "...", guard: "...", action: "..." }
Dynamic forms and field options
Each node can expose fields for its active step.
Supported features include:
- required fields,
- read-only fields,
- placeholders and help text,
- min/max validation,
- regex validation,
- static options,
- SQL-backed options,
- field visibility rules.
Form types
| Form type | Meaning |
|---|---|
Dynamic |
Render directly from workflow field definitions |
External |
Redirect to an external page |
Component |
Render through a named frontend component |
None |
No form is rendered |
SQL-backed options
Selectable fields such as Dropdown, Radio, and MultiSelect can use OptionsQuery.
Important design detail:
- SQL stays on the server,
- the browser does not receive raw SQL in the runtime payload,
- the frontend resolves options through
/instances/{id}/field-options/{fieldName}.
Field visibility
The field model also supports:
VisibilityGuardVisibilityGuardHandlerVisibilityGuardHandlers
This allows more advanced runtime UX where fields appear only under specific conditions.
Database providers and persistence
The package supports several persistence strategies.
In-memory
Useful for:
- development,
- demos,
- tests,
- quick prototypes.
SQL Server
Useful when the host runs on Microsoft SQL Server.
PostgreSQL
Useful when the host runs on PostgreSQL.
Migrations
If AutoMigrate is enabled, packaged FluentMigrator migrations run during UseChdWorkflowAsync().
Custom repository
If you need a custom persistence model, replace the default repository registration with your own IWorkflowRepository implementation.
builder.AddWorkflow(o =>
{
o.DatabaseProvider = "InMemory";
});
builder.Services.AddScoped<IWorkflowRepository, MyCustomRepository>();
The last registration wins, so the engine will use your repository.
Frontend integration
This package is designed to work with the React package in this repository:
- folder:
chd-workflow-react - npm package:
qp-workflow-react
A common full-stack usage pattern is:
- host the backend with
Chd.Workflow, - expose the workflow API,
- use the React designer to manage definitions,
- use the React runner to execute instances.
Example React designer
import { TreeDesigner } from 'qp-workflow-react';
<TreeDesigner
apiUrl="http://localhost:5035/api/workflow"
initialDefinitionId="leave-request"
locale="en"
enableFormEditor
onSaved={(definition) => console.log(definition.id)}
/>
Example React runner
import { WorkflowRunner } from 'qp-workflow-react';
<WorkflowRunner
apiUrl="http://localhost:5035/api/workflow"
instanceId="instance-123"
locale="en"
theme="dark"
/>
Migration advice
If you already have manual workflow logic in a legacy project, move gradually.
Suggested migration steps
- identify the hidden state machine in existing code,
- extract meaningful side effects into workflow actions,
- move pre-check logic into guards,
- move branching decisions into rules when needed,
- start with one business flow first,
- use feature flags if you want a low-risk rollout,
- remove old routing logic only after the new flow is stable.
Two migration styles are supported
You do not need to choose only one style for the whole application.
1. Modern workflow-first style
Use this for new code.
- write dedicated
IWorkflowActionclasses, - keep workflow side effects explicit,
- keep routing and rules fully workflow-oriented.
2. Legacy controller-friendly style
Use this when an old project already has stable controller or service methods.
- keep the existing request DTO,
- keep the existing response DTO,
- add
[WorkflowLegacyAction]to the method, - map the transition
ActionHandlerto that action name, - migrate deeper refactoring later.
This means you can start with low-change integration first, then move selected flows to dedicated workflow action classes over time.
Good first migration targets
These are usually good first candidates:
- leave requests,
- expense approval,
- procurement requests,
- budget approval,
- internal review flows,
- onboarding forms.
Example definition
Below is a simplified example workflow definition.
{
"id": "leave-request",
"name": "Leave Request Workflow",
"description": "Approval flow for employee leave requests",
"version": 1,
"isActive": true,
"rootNode": {
"id": "start",
"name": "Start",
"type": "Start",
"children": [
{
"id": "submit-request",
"name": "Submit Request",
"type": "Task",
"formType": "Dynamic",
"fields": [
{
"name": "days",
"label": "Days",
"type": "Number",
"required": true,
"readOnly": false,
"order": 1,
"min": 1,
"max": 30
},
{
"name": "reason",
"label": "Reason",
"type": "TextArea",
"required": true,
"readOnly": false,
"order": 2
}
],
"transitions": [
{
"id": "tr-submit",
"action": "submit",
"label": "Submit",
"targetNodeId": "manager-review",
"actionHandler": "submit-leave-request",
"order": 1,
"requiresConfirmation": false,
"requiresComment": false
}
],
"children": [
{
"id": "manager-review",
"name": "Manager Review",
"type": "Task",
"allowedRoles": ["Manager"],
"fields": [],
"transitions": [
{
"id": "tr-approve",
"action": "approve",
"label": "Approve",
"targetNodeId": "approved",
"buttonStyle": "success",
"order": 1,
"requiresConfirmation": true,
"requiresComment": false
},
{
"id": "tr-reject",
"action": "reject",
"label": "Reject",
"targetNodeId": "rejected",
"buttonStyle": "danger",
"order": 2,
"requiresConfirmation": false,
"requiresComment": true
}
],
"children": [
{
"id": "approved",
"name": "Approved",
"type": "End",
"children": [],
"fields": [],
"transitions": [],
"rules": [],
"allowedRoles": [],
"order": 1
},
{
"id": "rejected",
"name": "Rejected",
"type": "End",
"children": [],
"fields": [],
"transitions": [],
"rules": [],
"allowedRoles": [],
"order": 2
}
],
"rules": [],
"order": 1
}
],
"rules": [],
"allowedRoles": [],
"order": 1
}
],
"fields": [],
"transitions": [],
"rules": [],
"allowedRoles": [],
"order": 0
},
"createdAt": "2026-01-01T00:00:00Z"
}
Troubleshooting
Definitions save but enum values fail to bind
Use the package registration path so workflow JSON options are configured correctly.
Actions, guards, or rules do not appear
Check that:
- the host calls
builder.AddWorkflow(...), - attributes are applied correctly,
- the related assembly is reachable by the host,
- the application is using the expected startup path.
Dropdown options are empty
Inspect:
GET /api/workflow/instances/{id}/field-options/{fieldName}
If this is empty, review the field definition, the query, and the runtime data used by the query.
Notification settings endpoint returns 503
This usually means the host has not registered the required notification admin settings service.
Participant groups are empty in the designer
Implement IWorkflowParticipantDirectory.ListGroupsAsync(...) and register the participant directory in the host.
External forms do not return correctly
Check that the external page reads workflow parameters correctly and transitions the instance with the correct action and instance id.
License
MIT License. See LICENSE.
If you also use the frontend package from this repository, read chd-workflow-react/README.md for the visual designer, runtime runner, hooks, client API, localization, and external form helpers.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- FluentMigrator (>= 5.2.0)
- FluentMigrator.Runner (>= 5.2.0)
- FluentMigrator.Runner.Postgres (>= 5.2.0)
- FluentMigrator.Runner.SqlServer (>= 5.2.0)
- Microsoft.Data.SqlClient (>= 6.1.6)
- Microsoft.EntityFrameworkCore (>= 10.0.11)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.11)
- Microsoft.EntityFrameworkCore.SqlServer (>= 10.0.11)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.