Skip to content

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.

  • A .NET 10 project with Granit module system configured
  • A PostgreSQL (or other EF Core-supported) database
  • For approval routing: Granit.Identity.Keycloak (or another IIdentityProvider)
  • For notifications: Granit.Notifications installed
Terminal window
# Core FSM engine
dotnet add package Granit.Workflow
# EF Core interceptor and audit trail
dotnet 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.Notifications

Define an enum representing the possible states of your entity:

public enum DocumentStatus
{
Draft,
PendingReview,
Published,
Archived
}

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

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; }
}

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
}
}
// Core workflow engine
builder.Services.AddGranitWorkflow();
builder.Services.AddWorkflow(definition);
// EF Core interceptor + audit trail
builder.Services.AddGranitWorkflowEntityFrameworkCore<AppDbContext>();
// Resolves approvers from IIdentityProvider + IPermissionManager
builder.Services.AddGranitIdentityKeycloak();
builder.Services.AddIdentityApproverResolver();
builder.Services.AddGranitWorkflowNotifications();
builder.Services.AddGranitWorkflowEndpoints();
// After app.Build()
app.MapWorkflowEndpoints();

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

When a user triggers a transition marked with RequiresApproval() but lacks the required permission:

  1. The entity moves to PendingReview instead of the target state
  2. A WorkflowApprovalRequested domain event is published
  3. If Granit.Workflow.Notifications is installed, designated approvers receive a notification
  4. An approver (a user with the required permission) completes the transition

The built-in IdentityApproverResolver follows this flow:

  1. Permission to roles: queries IPermissionManager.GetGrantedRolesAsync() to find roles with the required permission
  2. Roles to users: for each role, queries IIdentityProvider.GetRoleMembersAsync() to get members
  3. User IDs are deduplicated and returned

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

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

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

The Granit.Workflow.Endpoints package exposes a single endpoint:

MethodRouteDescription
GET/workflow/{entityType}/{entityId}/historyAudit trail of all transitions