A lightweight, durable workflow engine for .NET 8. Define state-machine workflows with a fluent API, persist them with Entity Framework Core, and run them with built-in retry logic, timeout handling, and human override capabilities.
- Fluent Workflow Definition - Define states, transitions, and steps in clean C# code
- Durable State Persistence - EF Core code-first with full migration support
- Database Agnostic - SQL Server, PostgreSQL, MongoDB, and Redis providers included; extensible to any EF Core provider
- Event-Driven Transitions - Trigger state changes from external events
- Time-Based Transitions - Automatic timeouts that move workflows to a target state
- Transition Guards - Conditional transitions with custom guard logic
- Workflow Hooks - React to state changes, step completion, and workflow lifecycle events
- Step Execution - Run custom logic on state entry/exit with dependency injection
- Retry with Exponential Backoff - Failed steps are automatically retried
- Concurrency Safe - Atomic step claiming prevents duplicate execution by parallel processors
- Unit of Work - All state transitions are committed in a single atomic transaction
- Transactional Outbox - Reliable outbound messaging pattern built in
- Human Overrides - Skip steps, retry failed steps, force state transitions, suspend/resume workflows
- Query Builder - Fluent query API for dashboards with filtering, sorting, and pagination
- Health Checks - Monitor workflow engine health (stuck steps, failed steps, connectivity)
- Full Audit Trail - Every action is logged for traceability
- Dead Letter Queue - Failed messages are captured for manual review
# For SQL Server
dotnet add package WGC.WorkflowEngine.SqlServer
# For PostgreSQL
dotnet add package WGC.WorkflowEngine.PostgreSQL
# For MongoDB
dotnet add package WGC.WorkflowEngine.MongoDB
# For Redis
dotnet add package WGC.WorkflowEngine.Redisusing WGC.WorkflowEngine.Core.Builders;
using WGC.WorkflowEngine.Core.Interfaces;
public class OrderWorkflow : WorkflowDefinition
{
public override string Name => "OrderProcessing";
public override void Define(IWorkflowBuilder builder)
{
builder.InitialState("Pending");
builder.State("Pending")
.On("PaymentReceived").TransitionTo("Processing")
.On("Cancelled").TransitionTo("Cancelled")
.OnTimeout(TimeSpan.FromDays(3), "Expired")
.OnEnter(step => step.Execute<SendConfirmationEmailStep>());
builder.State("Processing")
.On("Shipped").TransitionTo("Shipped")
.OnEnter(step => step.Execute<NotifyWarehouseStep>());
builder.State("Shipped")
.On("Delivered").TransitionTo("Completed")
.OnEnter(step => step.Execute<SendTrackingEmailStep>());
builder.State("Completed").AsFinal();
builder.State("Cancelled").AsFinal().AllowReopen("Pending");
builder.State("Expired").AsFinal();
}
}using WGC.WorkflowEngine.Core.Interfaces;
public class SendConfirmationEmailStep : IStepHandler
{
private readonly IEmailService _emailService;
public SendConfirmationEmailStep(IEmailService emailService)
{
_emailService = emailService;
}
public async Task<StepResult> HandleAsync(StepExecutionContext context, CancellationToken ct)
{
var orderId = context.WorkflowInstance.CorrelationId;
await _emailService.SendAsync(orderId, "Your order has been received!");
return StepResult.Completed();
}
}// Program.cs
builder.Services
.AddWorkflowEngine(options =>
{
options.AddWorkflow<OrderWorkflow>();
options.AddStepHandler<SendConfirmationEmailStep>();
options.AddStepHandler<NotifyWarehouseStep>();
options.AddStepHandler<SendTrackingEmailStep>();
})
.UseWorkflowSqlServer(connectionString);
// or .UseWorkflowPostgreSql(connectionString);dotnet ef migrations add InitWorkflow --context WorkflowDbContext
dotnet ef database update --context WorkflowDbContextpublic class OrderController : ControllerBase
{
private readonly IWorkflowEngine _engine;
public OrderController(IWorkflowEngine engine) => _engine = engine;
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
// Start a new workflow
var instance = await _engine.StartAsync(
workflowName: "OrderProcessing",
correlationId: request.OrderId,
data: new { request.CustomerEmail, request.Items },
triggeredBy: User.Identity?.Name);
return Ok(new { WorkflowId = instance.Id });
}
[HttpPost("{id}/pay")]
public async Task<IActionResult> ProcessPayment(Guid id)
{
// Trigger a state transition
var instance = await _engine.TriggerEventAsync(id, "PaymentReceived");
return Ok(new { State = instance.CurrentState });
}
}Add conditions to transitions that must pass before a state change is allowed:
// Define a guard
public class HasQuoteGuard : ITransitionGuard
{
public Task<GuardResult> EvaluateAsync(TransitionContext context, CancellationToken ct)
{
var hasQuote = context.WorkflowData.ContainsKey("QuoteId");
return Task.FromResult(hasQuote
? GuardResult.Allow()
: GuardResult.Deny("No quote has been created yet."));
}
}
// Use in workflow definition
builder.State("Opportunity")
.On("QuoteCreated")
.WithGuard<HasQuoteGuard>()
.TransitionTo("Quote");
// Register the guard
options.AddGuard<HasQuoteGuard>();React to workflow lifecycle events (send emails, trigger integrations, update external systems):
public class SalesNotificationHook : IWorkflowHook
{
private readonly IEmailService _email;
public SalesNotificationHook(IEmailService email) => _email = email;
public async Task OnStateEnteredAsync(WorkflowInstance instance, string state, CancellationToken ct)
{
if (state == "Lead")
await _email.SendAsync("sales@company.com", $"New lead: {instance.CorrelationId}");
}
public async Task OnWorkflowCompletedAsync(WorkflowInstance instance, CancellationToken ct)
{
await _email.SendAsync("manager@company.com", $"Workflow {instance.Id} completed.");
}
}
// Register
options.AddHook<SalesNotificationHook>();Available hook methods (all optional):
OnWorkflowStartedAsync- Workflow createdOnStateEnteredAsync- After transitioning to a new stateOnStateExitingAsync- Before leaving the current stateOnWorkflowCompletedAsync- Workflow reached a final stateOnStepCompletedAsync- A step finished successfullyOnStepFailedAsync- A step permanently failed (no more retries)
Query workflow instances with a fluent API for dashboards and reporting:
// Inject IWorkflowQueryService
private readonly IWorkflowQueryService _queryService;
// Paginated, filtered query
var result = await _queryService.QueryAsync(q => q
.ForWorkflow("SalesLifecycle")
.InStatus(WorkflowStatus.Active)
.InState("Quote")
.CreatedAfter(DateTime.UtcNow.AddDays(-30))
.OrderBy(WorkflowSortField.UpdatedAt, descending: true)
.Page(1, 20));
// result.Items -> List<WorkflowInstance>
// result.TotalCount -> Total matching records
// result.TotalPages -> Total pages
// result.HasNextPage -> Pagination helper
// Dashboard summary (counts by status and state)
var summary = await _queryService.GetSummaryAsync("SalesLifecycle");
// summary.CountByStatus -> { "Active": 42, "Completed": 100, ... }
// summary.CountByState -> { "Contact": 10, "Lead": 15, "Quote": 8, ... }Monitor the workflow engine in production:
// Program.cs
builder.Services.AddHealthChecks()
.AddWorkflowEngineHealthCheck(options =>
{
options.StuckStepThreshold = TimeSpan.FromMinutes(30);
options.MaxStuckStepsBeforeUnhealthy = 5;
options.MaxFailedStepsBeforeDegraded = 10;
});Reports:
- Healthy - Engine operating normally
- Degraded - Stuck or permanently failed steps detected
- Unhealthy - Database unreachable or too many stuck steps
The engine requires periodic background processing for scheduled steps, retries, and outbox messages. Integrate with your preferred scheduler:
// Example with a hosted service
public class WorkflowBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
public WorkflowBackgroundService(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = _serviceProvider.CreateScope();
var stepProcessor = scope.ServiceProvider.GetRequiredService<IStepProcessor>();
await stepProcessor.ProcessPendingStepsAsync(ct: ct);
await stepProcessor.ProcessScheduledStepsAsync(ct: ct);
await stepProcessor.ProcessRetryStepsAsync(ct: ct);
var outboxProcessor = scope.ServiceProvider.GetRequiredService<IOutboxProcessor>();
await outboxProcessor.ProcessBatchAsync(ct: ct);
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
}
}All operations are logged in the audit trail:
// Force a state transition (bypass normal rules and guards)
await engine.ForceStateAsync(instanceId, "Processing", "Manager approved skip", "admin@company.com");
// Suspend a workflow
await engine.SuspendAsync(instanceId, "Waiting for customer response", "agent@company.com");
// Resume a suspended workflow
await engine.ResumeAsync(instanceId, "agent@company.com");
// Cancel a workflow
await engine.CancelAsync(instanceId, "Customer requested cancellation", "agent@company.com");
// Retry a failed step
await engine.RetryStepAsync(instanceId, stepId, "admin@company.com");
// Skip a step
await engine.SkipStepAsync(instanceId, stepId, "Not applicable", "admin@company.com");Send reliable messages from your step handlers:
public class NotifyWarehouseStep : IStepHandler
{
private readonly IOutboxRepository _outbox;
public NotifyWarehouseStep(IOutboxRepository outbox) => _outbox = outbox;
public async Task<StepResult> HandleAsync(StepExecutionContext context, CancellationToken ct)
{
// Message is persisted in the same transaction as the step update
await _outbox.CreateAsync(new OutboxMessage
{
WorkflowInstanceId = context.WorkflowInstance.Id,
MessageType = "WarehouseNotification",
Payload = JsonSerializer.Serialize(new { OrderId = context.WorkflowInstance.CorrelationId }),
Destination = "warehouse-queue"
}, ct);
return StepResult.Completed();
}
}The dashboard provides a web UI for monitoring and managing workflows, accessible at a configurable route prefix (like Hangfire Dashboard).
- Overview - Status cards (active, completed, faulted, etc.), per-workflow summaries, recent transitions, stuck/failed step alerts
- Instances - Filterable list with pagination, detail view with data, steps, event log timeline, and action buttons (force state, suspend, resume, cancel)
- Definitions - View all registered workflows (code-first and database), read-only view for code-first, form-based editor for database definitions
- Definition Editor - Create/edit workflow definitions stored in the database with states, transitions, guards, timeouts, and steps — all through a visual form
// Program.cs
builder.Services
.AddWorkflowEngine(options =>
{
options.AddWorkflow<SalesWorkflow>(); // code-first still works
})
.UseWorkflowSqlServer(connectionString);
// Add the dashboard (configurable route prefix, optional auth policy)
builder.Services.AddWorkflowDashboard(options =>
{
options.RoutePrefix = "/workflowengine"; // default
options.AuthorizationPolicy = "AdminOnly"; // optional
options.Title = "My Workflow Dashboard"; // optional
});
// Middleware
app.UseStaticFiles(); // required for dashboard CSS/JS
app.MapWorkflowDashboard(); // maps Razor Pages + API endpoints| Page | Route | Description |
|---|---|---|
| Overview | {prefix} |
Health status, counters, workflow summaries, recent transitions |
| Instances | {prefix}/instances |
Filterable, paginated list of workflow instances |
| Instance Detail | {prefix}/instances/{id} |
Full detail with data, steps, event log, actions |
| Definitions | {prefix}/definitions |
List all workflows with source (Code/Database) badge |
| Definition View | {prefix}/definitions/view/{name} |
Read-only view of states and transitions |
| Definition Editor | {prefix}/definitions/editor/{id?} |
Form-based editor for database definitions |
Workflow definitions can be created via the dashboard form editor and stored in the database alongside code-first definitions. Both sources coexist — code-first definitions cannot be overwritten from the dashboard.
All dashboard data is served via a REST API at {prefix}/api/:
| Endpoint | Method | Description |
|---|---|---|
/api/overview |
GET | Dashboard overview data |
/api/recent-transitions |
GET | Recent event log entries |
/api/instances |
GET | Paginated, filtered instances |
/api/instances/{id} |
GET | Instance detail |
/api/instances/{id}/force-state |
POST | Force a state transition |
/api/instances/{id}/suspend |
POST | Suspend a workflow |
/api/instances/{id}/resume |
POST | Resume a suspended workflow |
/api/instances/{id}/cancel |
POST | Cancel a workflow |
/api/definitions |
GET | All definitions |
/api/definitions/{id} |
GET | Get definition by ID |
/api/definitions/by-name/{name} |
GET | Get definition config by name |
/api/definitions |
POST | Create a new database definition |
/api/definitions/{id} |
PUT | Update a database definition |
/api/definitions/{id} |
DELETE | Deactivate a database definition |
/api/definitions/validate |
POST | Validate a definition without saving |
WGC.WorkflowEngine/
├── src/
│ ├── WGC.WorkflowEngine.Core/ # Core engine, entities, interfaces, builders
│ ├── WGC.WorkflowEngine.Persistence/ # EF Core DbContext, configurations, repositories
│ ├── WGC.WorkflowEngine.SqlServer/ # SQL Server provider
│ ├── WGC.WorkflowEngine.PostgreSQL/ # PostgreSQL provider
│ ├── WGC.WorkflowEngine.MongoDB/ # MongoDB provider
│ ├── WGC.WorkflowEngine.Redis/ # Redis provider
│ └── WGC.WorkflowEngine.Dashboard/ # Embedded web dashboard (Razor Class Library)
├── tests/
│ └── WGC.WorkflowEngine.Tests/ # Unit and integration tests
├── samples/
│ ├── Sample.Sql/ # SQL Server sample app
│ ├── Sample.PostgreSQL/ # PostgreSQL sample app
│ ├── Sample.MongoDB/ # MongoDB sample app
│ ├── Sample.Redis/ # Redis sample app
│ ├── Sample.SqlFromNuget/ # Sample using NuGet packages
│ └── Sample.Shared/ # Shared workflows, seeds, and step handlers
├── WGC.WorkflowEngine.sln
├── README.md
└── LICENSE
The engine creates the following tables (via EF Core migrations):
| Table | Description |
|---|---|
WorkflowInstances |
Active and completed workflow instances |
WorkflowSteps |
Individual steps with execution status |
WorkflowEventLogs |
Full audit trail of every action |
OutboxMessages |
Transactional outbox for reliable messaging |
WorkflowDefinitions |
Database-stored workflow definitions (JSON) |
- .NET 8.0 or later
- One of: SQL Server 2016+, PostgreSQL 12+, MongoDB 5.0+, or Redis 6.0+
MIT License - see LICENSE for details.