Skip to content

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.

  • 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
PackageRoleDepends on
Granit.WorkflowFSM definitions, IWorkflowManager<TState>, domain eventsGranit.Timing
Granit.Workflow.EntityFrameworkCoreWorkflowTransitionInterceptor, IWorkflowHistoryQuery, IWorkflowTransitionRecorderGranit.Workflow, Granit.Persistence
Granit.Workflow.EndpointsGET /{entityType}/{entityId}/history (paginated audit trail)Granit.Workflow, Granit.Authorization
Granit.Workflow.NotificationsApproval notifications (InApp + Email), state change notifications (InApp + SignalR)Granit.Workflow
Granit.Templating.WorkflowReplaces NullTemplateTransitionHook with WorkflowTemplateTransitionHookGranit.Templating, Granit.Workflow
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();
}
}
Program.cs
app.MapWorkflowEndpoints();

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>>();

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
StateDescription
DraftBeing edited. Filtered by IPublishable (not visible in standard queries).
PendingReviewAwaiting approval from a user with the required permission.
PublishedActive published version. Exactly one per BusinessId (unique filtered index).
ArchivedFormer version, preserved for ISO 27001 audit trail (3-year retention).
// Use the built-in definition directly
services.AddSingleton<IWorkflowDefinition<WorkflowLifecycleStatus>>(
PublicationWorkflow.Default);

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),
};
}
}
OutcomeSucceededDescription
CompletedtrueEntity moved to the requested target state.
ApprovalRequestedtrueEntity routed to PendingReview — approvers notified.
DeniedfalseUser lacks permission, no approval routing available.
InvalidTransitionfalseNo transition defined from current state to target.

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:

ImplementationBehavior
NullApproverResolver (default)Returns empty list — no notifications sent.
IdentityApproverResolverQueries users who hold the required permission via Granit.Identity.
CustomRegister your own for department-based, hierarchy-based, or delegation logic.
// Register a custom approver resolver
services.AddWorkflowApproverResolver<DepartmentApproverResolver>();

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.

Every state change produces an INSERT-only WorkflowTransitionRecord. These records are never modified or deleted (soft-delete is explicitly prohibited):

ColumnTypeDescription
IdGuidSequential GUID (clustered index).
EntityTypestringLogical entity type (e.g. "Invoice", "Document").
EntityIdstringEntity identifier (string for polymorphism).
PreviousStatestringState before transition.
NewStatestringState after transition.
TransitionedAtDateTimeOffsetUTC timestamp from IClock.Now.
TransitionedBystringUser ID or "system" for automated transitions.
Commentstring?Optional regulatory justification.
TenantIdGuid?Tenant context (null when multi-tenancy inactive).

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.

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 disposes

The interceptor reads WorkflowTransitionContext.Current?.Comment and stores it in the transition record.

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 after AuditedEntityInterceptor)
  • IWorkflowTransitionRecorderEfWorkflowTransitionRecorder
  • IWorkflowHistoryQueryDefaultWorkflowHistoryQuery

Granit.Workflow.Endpoints exposes a single read endpoint:

MethodRoutePermissionDescription
GET/{entityType}/{entityId}/historyWorkflow.History.ReadPaginated ISO 27001 audit trail
Program.cs
app.MapWorkflowEndpoints();

Query parameters: page (default 1), pageSize (default 20, max 100).

Granit.Workflow.Notifications provides two notification types:

Notification typeSeverityDefault channelsTrigger
WorkflowApprovalNotificationTypeWarningInApp + EmailWorkflowApprovalRequested domain event
WorkflowStateChangedNotificationTypeInfoInApp + SignalRWorkflowStateChangedEvent domain event

Wolverine handlers consume the domain events and dispatch notifications via the Granit.Notifications engine.

EventPublished whenPayload
WorkflowTransitioned<TState>Transition completes (generic, app-level)EntityType, EntityId, PreviousState, NewState, TransitionedBy
WorkflowStateChangedEventTransition completes (non-generic, framework-level)EntityType, EntityId, PreviousState, NewState, TransitionedBy
WorkflowApprovalRequestedTransition routed to approvalEntityType, EntityId, RequestedBy, TargetState, RequiredPermission
CategoryKey typesPackage
ModuleGranitWorkflowModule, GranitWorkflowEntityFrameworkCoreModule, GranitWorkflowEndpointsModule, GranitWorkflowNotificationsModule, GranitTemplatingWorkflowModule
DefinitionWorkflowDefinition<TState>, WorkflowDefinitionBuilder<TState>, IWorkflowDefinition<TState>Granit.Workflow
TransitionWorkflowTransition<TState>, TransitionBuilder<TState>, TransitionResult<TState>, TransitionOutcomeGranit.Workflow
ManagerIWorkflowManager<TState>, WorkflowManager<TState>Granit.Workflow
MarkerIWorkflowStateful, VersionedWorkflowEntityGranit.Workflow
ContextWorkflowTransitionContext, TransitionContextGranit.Workflow
PermissionIWorkflowPermissionCheckerGranit.Workflow
AuditWorkflowTransitionRecord, IWorkflowTransitionRecorder, IWorkflowHistoryQueryGranit.Workflow / .EntityFrameworkCore
DomainWorkflowLifecycleStatus, PublicationWorkflowGranit.Workflow
EventsWorkflowTransitioned<TState>, WorkflowStateChangedEvent, WorkflowApprovalRequestedGranit.Workflow
EF CoreIWorkflowDbContext, WorkflowTransitionInterceptorGranit.Workflow.EntityFrameworkCore
NotificationsIApproverResolver, WorkflowApprovalNotificationType, WorkflowStateChangedNotificationTypeGranit.Workflow.Notifications
ExtensionsAddGranitWorkflow(), AddGranitWorkflowEntityFrameworkCore<T>(), AddGranitWorkflowEndpoints(), AddGranitWorkflowNotifications(), MapWorkflowEndpoints()