Granit.Workflow
Granit.Workflow provides a type-safe finite state machine (FSM) engine for managing entity lifecycles. Workflow definitions are built via a fluent API, validated at build time (no duplicate transitions, no unreachable states), and stored as immutable singletons. Transitions support permission checking, Odoo-style approval routing, and an INSERT-only audit trail for ISO 27001 compliance.
Package structure
Section titled “Package structure”DirectoryGranit.Workflow/ Core FSM engine, fluent builder, domain events
- Granit.Workflow.EntityFrameworkCore EF Core interceptor, audit trail persistence
- Granit.Workflow.Endpoints Minimal API for transition history
- Granit.Workflow.Notifications Approval and state change notifications
- Granit.Templating.Workflow Bridge: template lifecycle via Workflow FSM
| Package | Role | Depends on |
|---|---|---|
Granit.Workflow | FSM definitions, IWorkflowManager<TState>, domain events | Granit.Timing |
Granit.Workflow.EntityFrameworkCore | WorkflowTransitionInterceptor, IWorkflowHistoryQuery, IWorkflowTransitionRecorder | Granit.Workflow, Granit.Persistence |
Granit.Workflow.Endpoints | GET /{entityType}/{entityId}/history (paginated audit trail) | Granit.Workflow, Granit.Authorization |
Granit.Workflow.Notifications | Approval notifications (InApp + Email), state change notifications (InApp + SignalR) | Granit.Workflow |
Granit.Templating.Workflow | Replaces NullTemplateTransitionHook with WorkflowTemplateTransitionHook | Granit.Templating, Granit.Workflow |
Dependency graph
Section titled “Dependency graph”graph TD
W[Granit.Workflow] --> T[Granit.Timing]
WEF[Granit.Workflow.EntityFrameworkCore] --> W
WEF --> P[Granit.Persistence]
WE[Granit.Workflow.Endpoints] --> W
WE --> A[Granit.Authorization]
WN[Granit.Workflow.Notifications] --> W
TW[Granit.Templating.Workflow] --> W
TW --> TM[Granit.Templating]
[DependsOn(typeof(GranitWorkflowEntityFrameworkCoreModule))][DependsOn(typeof(GranitWorkflowEndpointsModule))][DependsOn(typeof(GranitWorkflowNotificationsModule))]public class AppModule : GranitModule{ public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddGranitWorkflowEntityFrameworkCore<AppDbContext>(); context.Services.AddGranitWorkflowEndpoints(); context.Services.AddGranitWorkflowNotifications(); }}app.MapWorkflowEndpoints();[DependsOn(typeof(GranitWorkflowModule))]public class AppModule : GranitModule { }Use this when you only need the FSM engine and domain events without EF Core persistence or HTTP endpoints.
Defining a workflow
Section titled “Defining a workflow”Workflow definitions are generic over an enum state type. The fluent builder
validates the graph at build time: initial state must be set, at least one transition
must exist, no duplicate From+To pairs, and all states must be reachable from the
initial state via BFS.
public enum InvoiceStatus{ Draft, PendingApproval, Approved, Rejected, Paid,}
public static class InvoiceWorkflow{ public static WorkflowDefinition<InvoiceStatus> Default { get; } = WorkflowDefinition<InvoiceStatus>.Create(builder => builder .InitialState(InvoiceStatus.Draft) .Transition(InvoiceStatus.Draft, InvoiceStatus.PendingApproval, t => t .Named("Submit for approval") .RequiresPermission("invoice.submit")) .Transition(InvoiceStatus.PendingApproval, InvoiceStatus.Approved, t => t .Named("Approve") .RequiresPermission("invoice.approve") .RequiresApproval()) .Transition(InvoiceStatus.PendingApproval, InvoiceStatus.Rejected, t => t .Named("Reject") .RequiresPermission("invoice.approve")) .Transition(InvoiceStatus.Approved, InvoiceStatus.Paid, t => t .Named("Mark as paid") .RequiresPermission("invoice.pay")));}Register the definition as a singleton:
services.AddSingleton<IWorkflowDefinition<InvoiceStatus>>(InvoiceWorkflow.Default);services.AddScoped<IWorkflowManager<InvoiceStatus>, WorkflowManager<InvoiceStatus>>();Built-in publication workflow
Section titled “Built-in publication workflow”Granit ships a pre-built PublicationWorkflow.Default for the standard publication
lifecycle. It uses WorkflowLifecycleStatus as the state enum:
stateDiagram-v2
[*] --> Draft
Draft --> PendingReview : Submit for review
Draft --> Published : Direct publish (approval)
PendingReview --> Published : Approve and publish
Published --> Archived : Archive
Published --> Draft : Create new version
| State | Description |
|---|---|
Draft | Being edited. Filtered by IPublishable (not visible in standard queries). |
PendingReview | Awaiting approval from a user with the required permission. |
Published | Active published version. Exactly one per BusinessId (unique filtered index). |
Archived | Former version, preserved for ISO 27001 audit trail (3-year retention). |
// Use the built-in definition directlyservices.AddSingleton<IWorkflowDefinition<WorkflowLifecycleStatus>>( PublicationWorkflow.Default);Transitioning state
Section titled “Transitioning state”IWorkflowManager<TState> orchestrates transitions with permission checking and
approval routing:
public static class ApproveInvoiceHandler{ public static async Task<IResult> Handle( Guid invoiceId, InvoiceDbContext db, IWorkflowManager<InvoiceStatus> workflowManager, CancellationToken cancellationToken) { Invoice invoice = await db.Invoices.FindAsync([invoiceId], cancellationToken) ?? throw new EntityNotFoundException(typeof(Invoice), invoiceId);
TransitionResult<InvoiceStatus> result = await workflowManager.TransitionAsync( invoice.Status, InvoiceStatus.Approved, new TransitionContext { Comment = "Approved per department policy" }, cancellationToken).ConfigureAwait(false);
return result.Outcome switch { TransitionOutcome.Completed => TypedResults.Ok(), TransitionOutcome.ApprovalRequested => TypedResults.Accepted( value: "Routed to approval"), TransitionOutcome.Denied => TypedResults.Problem( detail: "Insufficient permissions", statusCode: 403), TransitionOutcome.InvalidTransition => TypedResults.Problem( detail: "Invalid transition", statusCode: 422), _ => TypedResults.Problem(detail: "Unexpected outcome", statusCode: 500), }; }}Transition outcomes
Section titled “Transition outcomes”| Outcome | Succeeded | Description |
|---|---|---|
Completed | true | Entity moved to the requested target state. |
ApprovalRequested | true | Entity routed to PendingReview — approvers notified. |
Denied | false | User lacks permission, no approval routing available. |
InvalidTransition | false | No transition defined from current state to target. |
Approval routing
Section titled “Approval routing”When a user triggers a transition that requires a permission they lack, and the
transition has RequiresApproval() enabled, the workflow manager routes the entity to
a pending review state and publishes a WorkflowApprovalRequested domain event:
sequenceDiagram
participant U as User (no publish permission)
participant WM as IWorkflowManager
participant PC as IWorkflowPermissionChecker
participant EV as Domain Events
U->>WM: TransitionAsync(Draft → Published)
WM->>PC: IsGrantedAsync("workflow.publish")
PC-->>WM: false
Note over WM: RequiresApproval = true
WM-->>U: ApprovalRequested (→ PendingReview)
WM->>EV: WorkflowApprovalRequested
Note over EV: Notifications module sends InApp + Email to approvers
The IApproverResolver interface determines who receives the notification:
| Implementation | Behavior |
|---|---|
NullApproverResolver (default) | Returns empty list — no notifications sent. |
IdentityApproverResolver | Queries users who hold the required permission via Granit.Identity. |
| Custom | Register your own for department-based, hierarchy-based, or delegation logic. |
// Register a custom approver resolverservices.AddWorkflowApproverResolver<DepartmentApproverResolver>();IWorkflowStateful marker
Section titled “IWorkflowStateful marker”Entities implementing IWorkflowStateful get automatic audit trail recording via the
WorkflowTransitionInterceptor. The interface uses C# 11+ static abstract members to
avoid instance-level overhead:
public class Invoice : AuditedEntity, IWorkflowStateful{ public InvoiceStatus Status { get; set; } public decimal Amount { get; set; }
// Static abstract members — resolved via reflection by the interceptor static string IWorkflowStateful.StatusPropertyName => nameof(Status); static string IWorkflowStateful.WorkflowEntityType => "Invoice";
public string GetWorkflowEntityId() => Id.ToString();}For entities that combine versioning with workflow (Odoo-style documents), inherit from
VersionedWorkflowEntity:
public class DocumentRevision : VersionedWorkflowEntity{ public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;
// Only need to provide the entity type name static string IWorkflowStateful.WorkflowEntityType => "Document";}VersionedWorkflowEntity provides BusinessId, Version, LifecycleStatus, and
IsPublished — the interceptor keeps IsPublished in sync with the lifecycle status.
ISO 27001 audit trail
Section titled “ISO 27001 audit trail”WorkflowTransitionRecord
Section titled “WorkflowTransitionRecord”Every state change produces an INSERT-only WorkflowTransitionRecord. These records
are never modified or deleted (soft-delete is explicitly prohibited):
| Column | Type | Description |
|---|---|---|
Id | Guid | Sequential GUID (clustered index). |
EntityType | string | Logical entity type (e.g. "Invoice", "Document"). |
EntityId | string | Entity identifier (string for polymorphism). |
PreviousState | string | State before transition. |
NewState | string | State after transition. |
TransitionedAt | DateTimeOffset | UTC timestamp from IClock.Now. |
TransitionedBy | string | User ID or "system" for automated transitions. |
Comment | string? | Optional regulatory justification. |
TenantId | Guid? | Tenant context (null when multi-tenancy inactive). |
WorkflowTransitionInterceptor
Section titled “WorkflowTransitionInterceptor”The interceptor hooks into EF Core SaveChanges and detects state changes by comparing
OriginalValues vs CurrentValues on the property identified by
IWorkflowStateful.StatusPropertyName. When a difference is detected, a new
WorkflowTransitionRecord is added to the same transaction.
The interceptor also synchronizes IPublishable.IsPublished for entities implementing
both IPublishable and IWorkflowStateful — set to true only when the status is
WorkflowLifecycleStatus.Published.
AsyncLocal comment propagation
Section titled “AsyncLocal comment propagation”Attach a regulatory comment to the current transition via WorkflowTransitionContext:
using (WorkflowTransitionContext.SetComment("Validated by medical director per ISO 27001")){ invoice.Status = InvoiceStatus.Approved; await dbContext.SaveChangesAsync(cancellationToken);}// Comment is automatically cleared when the scope disposesThe interceptor reads WorkflowTransitionContext.Current?.Comment and stores it in
the transition record.
EF Core integration
Section titled “EF Core integration”The host application’s DbContext must implement IWorkflowDbContext and call the
model configuration extension:
public class AppDbContext : DbContext, IWorkflowDbContext{ public DbSet<WorkflowTransitionRecord> WorkflowTransitionRecords => Set<WorkflowTransitionRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ConfigureWorkflowModule(); modelBuilder.ApplyGranitConventions(currentTenant, dataFilter); }}Register the EF Core services:
services.AddGranitWorkflowEntityFrameworkCore<AppDbContext>();This registers:
WorkflowTransitionInterceptor(Scoped, ordered afterAuditedEntityInterceptor)IWorkflowTransitionRecorder→EfWorkflowTransitionRecorderIWorkflowHistoryQuery→DefaultWorkflowHistoryQuery
Endpoints
Section titled “Endpoints”Granit.Workflow.Endpoints exposes a single read endpoint:
| Method | Route | Permission | Description |
|---|---|---|---|
GET | /{entityType}/{entityId}/history | Workflow.History.Read | Paginated ISO 27001 audit trail |
app.MapWorkflowEndpoints();Query parameters: page (default 1), pageSize (default 20, max 100).
Notifications
Section titled “Notifications”Granit.Workflow.Notifications provides two notification types:
| Notification type | Severity | Default channels | Trigger |
|---|---|---|---|
WorkflowApprovalNotificationType | Warning | InApp + Email | WorkflowApprovalRequested domain event |
WorkflowStateChangedNotificationType | Info | InApp + SignalR | WorkflowStateChangedEvent domain event |
Wolverine handlers consume the domain events and dispatch notifications via the
Granit.Notifications engine.
Domain events
Section titled “Domain events”| Event | Published when | Payload |
|---|---|---|
WorkflowTransitioned<TState> | Transition completes (generic, app-level) | EntityType, EntityId, PreviousState, NewState, TransitionedBy |
WorkflowStateChangedEvent | Transition completes (non-generic, framework-level) | EntityType, EntityId, PreviousState, NewState, TransitionedBy |
WorkflowApprovalRequested | Transition routed to approval | EntityType, EntityId, RequestedBy, TargetState, RequiredPermission |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitWorkflowModule, GranitWorkflowEntityFrameworkCoreModule, GranitWorkflowEndpointsModule, GranitWorkflowNotificationsModule, GranitTemplatingWorkflowModule | — |
| Definition | WorkflowDefinition<TState>, WorkflowDefinitionBuilder<TState>, IWorkflowDefinition<TState> | Granit.Workflow |
| Transition | WorkflowTransition<TState>, TransitionBuilder<TState>, TransitionResult<TState>, TransitionOutcome | Granit.Workflow |
| Manager | IWorkflowManager<TState>, WorkflowManager<TState> | Granit.Workflow |
| Marker | IWorkflowStateful, VersionedWorkflowEntity | Granit.Workflow |
| Context | WorkflowTransitionContext, TransitionContext | Granit.Workflow |
| Permission | IWorkflowPermissionChecker | Granit.Workflow |
| Audit | WorkflowTransitionRecord, IWorkflowTransitionRecorder, IWorkflowHistoryQuery | Granit.Workflow / .EntityFrameworkCore |
| Domain | WorkflowLifecycleStatus, PublicationWorkflow | Granit.Workflow |
| Events | WorkflowTransitioned<TState>, WorkflowStateChangedEvent, WorkflowApprovalRequested | Granit.Workflow |
| EF Core | IWorkflowDbContext, WorkflowTransitionInterceptor | Granit.Workflow.EntityFrameworkCore |
| Notifications | IApproverResolver, WorkflowApprovalNotificationType, WorkflowStateChangedNotificationType | Granit.Workflow.Notifications |
| Extensions | AddGranitWorkflow(), AddGranitWorkflowEntityFrameworkCore<T>(), AddGranitWorkflowEndpoints(), AddGranitWorkflowNotifications(), MapWorkflowEndpoints() | — |
See also
Section titled “See also”- Core module —
IDomainEvent, module system,IPublishable - Persistence module —
AuditedEntityInterceptor,ApplyGranitConventions - Security module —
ICurrentUserService, permission checking - Notifications module — Notification engine, channels
- Wolverine module — Domain event routing, transactional outbox
- API Reference (auto-generated from XML docs)