Implement a workflow
Granit.Workflow provides a generic finite state machine (FSM) engine for managing entity lifecycles. It supports permission-based guards, automatic approval routing, ISO 27001 audit trails, and integration with the notification system.
Prerequisites
Section titled “Prerequisites”- A .NET 10 project with Granit module system configured
- A PostgreSQL (or other EF Core-supported) database
- For approval routing:
Granit.Identity.Keycloak(or anotherIIdentityProvider) - For notifications:
Granit.Notificationsinstalled
Step 1 — Install packages
Section titled “Step 1 — Install packages”# Core FSM enginedotnet add package Granit.Workflow
# EF Core interceptor and audit traildotnet add package Granit.Workflow.EntityFrameworkCore
# REST endpoints for transition history (optional)dotnet add package Granit.Workflow.Endpoints
# Notification integration for approvals (optional)dotnet add package Granit.Workflow.NotificationsStep 2 — Define states
Section titled “Step 2 — Define states”Define an enum representing the possible states of your entity:
public enum DocumentStatus{ Draft, PendingReview, Published, Archived}Step 3 — Build a workflow definition
Section titled “Step 3 — Build a workflow definition”A WorkflowDefinition<TState> is an immutable singleton that describes all
allowed transitions:
using Granit.Workflow;
WorkflowDefinition<DocumentStatus> definition = WorkflowDefinition<DocumentStatus>.Create(b => b .InitialState(DocumentStatus.Draft) .Transition(DocumentStatus.Draft, DocumentStatus.PendingReview, t => t .Named("Submit for review") .RequiresPermission("document.submit")) .Transition(DocumentStatus.PendingReview, DocumentStatus.Published, t => t .Named("Publish") .RequiresPermission("document.publish") .RequiresApproval()) .Transition(DocumentStatus.Published, DocumentStatus.Archived, t => t .Named("Archive") .RequiresPermission("document.archive")) .Transition(DocumentStatus.Draft, DocumentStatus.Published, t => t .Named("Direct publish") .RequiresPermission("document.publish")));Graph validation
Section titled “Graph validation”The builder validates the workflow graph at Build() time:
- An initial state must be declared
- At least one transition must exist
- No duplicate transitions (same source and target)
- All states must be reachable from the initial state (BFS traversal)
Invalid definitions throw at startup, not at runtime.
Step 4 — Make your entity workflow-aware
Section titled “Step 4 — Make your entity workflow-aware”Implement IWorkflowStateful<TState> on your entity:
public sealed class Document : Entity, IWorkflowStateful<DocumentStatus>{ public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; public DocumentStatus LifecycleStatus { get; set; } = DocumentStatus.Draft; public bool IsPublished { get; set; }}Inherit from VersionedWorkflowEntity for entities that need both FSM and
revision history:
public sealed class Document : VersionedWorkflowEntity{ public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;}VersionedWorkflowEntity provides BusinessId, Version,
LifecycleStatus, and IsPublished out of the box.
Step 5 — Configure DbContext
Section titled “Step 5 — Configure DbContext”Your application DbContext must implement IWorkflowDbContext to store
transition records:
public sealed class AppDbContext : DbContext, IWorkflowDbContext{ public DbSet<Document> Documents => Set<Document>(); public DbSet<WorkflowTransitionRecord> WorkflowTransitionRecords => Set<WorkflowTransitionRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ConfigureWorkflowModule(); // ... your entity configurations }}Step 6 — Register services
Section titled “Step 6 — Register services”// Core workflow enginebuilder.Services.AddGranitWorkflow();builder.Services.AddWorkflow(definition);
// EF Core interceptor + audit trailbuilder.Services.AddGranitWorkflowEntityFrameworkCore<AppDbContext>();Optional: approval routing
Section titled “Optional: approval routing”// Resolves approvers from IIdentityProvider + IPermissionManagerbuilder.Services.AddGranitIdentityKeycloak();builder.Services.AddIdentityApproverResolver();builder.Services.AddWorkflowApproverResolver<MyApproverResolver>();Optional: notifications
Section titled “Optional: notifications”builder.Services.AddGranitWorkflowNotifications();Optional: REST endpoints
Section titled “Optional: REST endpoints”builder.Services.AddGranitWorkflowEndpoints();
// After app.Build()app.MapWorkflowEndpoints();Step 7 — Trigger transitions
Section titled “Step 7 — Trigger transitions”Use IWorkflowManager<TState> to transition entities:
public sealed class DocumentService( AppDbContext dbContext, IWorkflowManager<DocumentStatus> workflowManager){ public async Task SubmitForReviewAsync( Guid documentId, CancellationToken cancellationToken) { var document = await dbContext.Documents.FindAsync( [documentId], cancellationToken);
ArgumentNullException.ThrowIfNull(document);
await workflowManager.TransitionAsync( document, DocumentStatus.PendingReview, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); }}Approval routing
Section titled “Approval routing”When a user triggers a transition marked with RequiresApproval() but lacks
the required permission:
- The entity moves to
PendingReviewinstead of the target state - A
WorkflowApprovalRequesteddomain event is published - If
Granit.Workflow.Notificationsis installed, designated approvers receive a notification - An approver (a user with the required permission) completes the transition
How approvers are resolved
Section titled “How approvers are resolved”The built-in IdentityApproverResolver follows this flow:
- Permission to roles: queries
IPermissionManager.GetGrantedRolesAsync()to find roles with the required permission - Roles to users: for each role, queries
IIdentityProvider.GetRoleMembersAsync()to get members - User IDs are deduplicated and returned
Audit trail (ISO 27001)
Section titled “Audit trail (ISO 27001)”The WorkflowTransitionInterceptor automatically creates a
WorkflowTransitionRecord on every state change detected in SaveChanges.
These records are INSERT-only — never modified or deleted.
Each record captures:
- Entity type and identifier
- Previous and new state
- UTC timestamp
- User identifier
- Optional comment (regulatory justification)
- Tenant context
IPublishable filter
Section titled “IPublishable filter”IPublishable is a global EF Core query filter (like IActive and
ISoftDeletable). By default, only entities with IsPublished = true are
returned by queries.
Disable the filter for admin views that need to see all versions:
using (dataFilter.Disable<IPublishable>()){ var allVersions = await dbContext.Documents.ToListAsync(cancellationToken);}Publication lifecycle
Section titled “Publication lifecycle”Granit provides a pre-built publication workflow via
PublicationWorkflow.Default:
Draft --> PendingReview --> Published --> Archived | ^ +----------> Published -----------------+ (direct publish)Register it instead of building your own:
builder.Services.AddWorkflow(PublicationWorkflow.Default);Adding a transition comment
Section titled “Adding a transition comment”Use WorkflowTransitionContext (AsyncLocal) to attach a regulatory
justification to the audit record:
using (WorkflowTransitionContext.SetComment("Approved per compliance review #42")){ await workflowManager.TransitionAsync( document, DocumentStatus.Published, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);}REST endpoint
Section titled “REST endpoint”The Granit.Workflow.Endpoints package exposes a single endpoint:
| Method | Route | Description |
|---|---|---|
GET | /workflow/{entityType}/{entityId}/history | Audit trail of all transitions |
Next steps
Section titled “Next steps”- Create document templates to use workflow-controlled publication for templates
- Set up end-to-end tracing to trace workflow transitions across Wolverine handlers
- Workflow reference for the complete API surface