Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part VIII: Workflow DSL -- Editorial Pipelines

Overview

The Workflow DSL defines editorial pipelines as state machines with guarded transitions. A [Workflow] attribute declares a named pipeline. [Stage] attributes declare the states. [Transition] attributes declare the allowed movements between states. [Gate] attributes declare guard conditions that must be satisfied before a transition can fire.

The source generator produces:

  • A stage enum with all declared stages
  • Transition validation logic (only declared transitions are allowed)
  • Gate evaluation (guards are checked before each transition)
  • Domain events on every transition (for audit trails and integrations)
  • Per-locale progress tracking (for multilingual content)
  • Scheduled transition execution (for timed publishing)

The developer defines the workflow declaratively. The compiler enforces it at runtime.


Core Attributes

Workflow

namespace Cmf.Workflow.Lib;

/// <summary>
/// Declares a named editorial workflow. Attached to entities
/// via [HasWorkflow]. A workflow defines the lifecycle stages
/// that content passes through before publication.
/// </summary>
[MetaConcept("Workflow")]
[MetaConstraint("MustHaveAtLeastTwoStages",
    "Stages.Count >= 2",
    Message = "Workflow must have at least two stages")]
[MetaConstraint("MustHaveInitialStage",
    "Stages.Any(s => s.IsInitial)",
    Message = "Workflow must have exactly one initial stage")]
public sealed class WorkflowAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("Description", "string")]
    public string? Description { get; set; }

    public WorkflowAttribute(string name) => Name = name;
}

HasWorkflow

/// <summary>
/// Attaches a workflow to an entity. The entity gains a CurrentStage
/// property and transition methods. Only one workflow can be attached
/// per entity.
/// </summary>
[MetaConcept("HasWorkflow")]
[MetaReference("Workflow", "Workflow", Multiplicity = "1")]
[MetaConstraint("TargetMustBeWorkflow",
    "ReferencedType.IsAnnotatedWith('Workflow')",
    Message = "HasWorkflow must reference a [Workflow] type")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class HasWorkflowAttribute : Attribute
{
    [MetaProperty("WorkflowName", "string", Required = true)]
    public string WorkflowName { get; }

    public HasWorkflowAttribute(string workflowName) => WorkflowName = workflowName;
}

Stage

/// <summary>
/// Declares a stage (state) in a workflow. Stages are the nodes
/// in the state machine. One stage must be marked as initial.
/// </summary>
[MetaConcept("Stage")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class StageAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("IsInitial", "bool")]
    public bool IsInitial { get; set; } = false;

    [MetaProperty("IsFinal", "bool")]
    public bool IsFinal { get; set; } = false;

    [MetaProperty("Description", "string")]
    public string? Description { get; set; }

    [MetaProperty("Color", "string")]
    public string? Color { get; set; }

    public StageAttribute(string name) => Name = name;
}

Transition

/// <summary>
/// Declares an allowed transition between two stages. Transitions
/// are the edges in the state machine. Each transition has a name
/// (the action verb) and from/to stages.
/// </summary>
[MetaConcept("Transition")]
[MetaReference("From", "Stage", Multiplicity = "1")]
[MetaReference("To", "Stage", Multiplicity = "1")]
[MetaConstraint("FromAndToMustDiffer",
    "From != To",
    Message = "Transition must connect two different stages")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class TransitionAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("From", "string", Required = true)]
    public string From { get; set; }

    [MetaProperty("To", "string", Required = true)]
    public string To { get; set; }

    [MetaProperty("Description", "string")]
    public string? Description { get; set; }

    public TransitionAttribute(string name) => Name = name;
}

Gate

/// <summary>
/// Declares a guard condition on a transition. The transition
/// cannot fire unless all gates evaluate to true. Gates enable
/// role-based access, approval workflows, and requirement
/// compliance checks.
/// </summary>
[MetaConcept("Gate")]
[MetaReference("Transition", "Transition", Multiplicity = "1")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class GateAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("Transition", "string", Required = true)]
    public string Transition { get; set; }

    [MetaProperty("GateType", "string")]
    public string GateType { get; set; } = "Custom";

    public GateAttribute(string name) => Name = name;
}

RequiresRole

/// <summary>
/// A built-in gate type that restricts a transition to users
/// with a specific role. Syntactic sugar for a role-check gate.
/// </summary>
[MetaConcept("RequiresRole")]
[MetaInherits("Gate")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class RequiresRoleAttribute : Attribute
{
    [MetaProperty("Transition", "string", Required = true)]
    public string Transition { get; set; }

    [MetaProperty("Role", "string", Required = true)]
    public string Role { get; set; }

    public RequiresRoleAttribute(string transition, string role)
    {
        Transition = transition;
        Role = role;
    }
}

RequiresApproval

/// <summary>
/// A built-in gate type that requires a minimum number of approvals
/// before a transition can fire. Approvals are recorded per-entity.
/// </summary>
[MetaConcept("RequiresApproval")]
[MetaInherits("Gate")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class RequiresApprovalAttribute : Attribute
{
    [MetaProperty("Transition", "string", Required = true)]
    public string Transition { get; set; }

    [MetaProperty("MinApprovers", "int", Required = true)]
    public int MinApprovers { get; set; }

    public RequiresApprovalAttribute(string transition, int minApprovers)
    {
        Transition = transition;
        MinApprovers = minApprovers;
    }
}

ForEachLocale

/// <summary>
/// Applied to a stage to indicate that progress is tracked per locale.
/// The stage is only considered complete when all configured locales
/// have completed it. Typically used on a "Translation" stage.
/// </summary>
[MetaConcept("ForEachLocale")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ForEachLocaleAttribute : Attribute
{
    [MetaProperty("Stage", "string", Required = true)]
    public string Stage { get; set; }

    public ForEachLocaleAttribute(string stage) => Stage = stage;
}

ScheduledTransition

/// <summary>
/// Declares a transition that fires automatically at a scheduled time.
/// The time is read from a DateTimeOffset property on the entity.
/// A background service polls for entities whose scheduled time has passed.
/// </summary>
[MetaConcept("ScheduledTransition")]
[MetaInherits("Transition")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ScheduledTransitionAttribute : Attribute
{
    [MetaProperty("From", "string", Required = true)]
    public string From { get; set; }

    [MetaProperty("To", "string", Required = true)]
    public string To { get; set; }

    [MetaProperty("DateProperty", "string", Required = true)]
    public string DateProperty { get; set; }

    public ScheduledTransitionAttribute(string from, string to, string dateProperty)
    {
        From = from;
        To = to;
        DateProperty = dateProperty;
    }
}

Complete Example: Editorial Workflow

What the Developer Writes

namespace MyStore.Lib.Workflows;

/// <summary>
/// Editorial workflow for blog posts. Content passes through
/// Draft → Review → Translation → Approved → Published, with
/// role gates, approval requirements, locale tracking, and
/// scheduled publishing.
/// </summary>
[Workflow("Editorial", Description = "Blog post editorial pipeline")]
[Stage("Draft", IsInitial = true, Color = "#9E9E9E",
    Description = "Initial authoring stage")]
[Stage("Review", Color = "#FF9800",
    Description = "Editorial review by editors")]
[Stage("Translation", Color = "#2196F3",
    Description = "Translation to all configured locales")]
[Stage("Approved", Color = "#4CAF50",
    Description = "Approved and awaiting publication")]
[Stage("Published", IsFinal = true, Color = "#1B5E20",
    Description = "Live and visible to readers")]
[Stage("Rejected", Color = "#F44336",
    Description = "Sent back for revision")]
// ── Transitions ──
[Transition("Submit", From = "Draft", To = "Review",
    Description = "Author submits for editorial review")]
[Transition("Approve", From = "Review", To = "Translation",
    Description = "Editor approves for translation")]
[Transition("Reject", From = "Review", To = "Rejected",
    Description = "Editor rejects with feedback")]
[Transition("TranslationComplete", From = "Translation", To = "Approved",
    Description = "All locale translations completed")]
[Transition("Publish", From = "Approved", To = "Published",
    Description = "Content goes live")]
[Transition("Revise", From = "Rejected", To = "Draft",
    Description = "Author revises after rejection")]
[Transition("Unpublish", From = "Published", To = "Draft",
    Description = "Take content offline for revision")]
// ── Gates ──
[RequiresRole("Submit", Role = "Author")]
[RequiresRole("Approve", Role = "Editor")]
[RequiresRole("Reject", Role = "Editor")]
[RequiresRole("Publish", Role = "Publisher")]
[RequiresRole("Unpublish", Role = "Publisher")]
[RequiresApproval("Approve", MinApprovers = 2)]
// ── Locale tracking ──
[ForEachLocale("Translation")]
// ── Scheduled publishing ──
[ScheduledTransition("Approved", "Published", DateProperty = "PublishDate")]
public partial class EditorialWorkflow { }

Attaching the Workflow to an Entity

[AggregateRoot("BlogPost", BoundedContext = "Content")]
[HasPart(typeof(RoutablePart))]
[HasPart(typeof(SeoablePart))]
[HasPart(typeof(VersionablePart))]
[HasWorkflow("Editorial")]
public partial class BlogPost
{
    [EntityId]
    public partial BlogPostId Id { get; }

    [Property("Title", Required = true, MaxLength = 200)]
    public partial string Title { get; }

    [Property("Author", Required = true)]
    public partial string Author { get; }

    [Property("PublishDate")]
    public partial DateTimeOffset? PublishDate { get; }

    [StreamField("Body", AllowedBlockTypes = new[]
    {
        typeof(HeroBlock), typeof(RichTextBlock),
        typeof(TestimonialBlock), typeof(ImageBlock)
    })]
    public partial IReadOnlyList<IContentBlock> Body { get; }
}

Workflow State Diagram

Diagram

What the Compiler Generates

1. Stage Enum

// Generated: EditorialWorkflowStage.g.cs
namespace MyStore.Lib.Workflows;

/// <summary>
/// Stages in the Editorial workflow. Generated from [Stage] attributes.
/// </summary>
public enum EditorialWorkflowStage
{
    Draft,
    Review,
    Translation,
    Approved,
    Published,
    Rejected
}

2. Transition Validator

// Generated: EditorialWorkflowTransitions.g.cs
public static class EditorialWorkflowTransitions
{
    private static readonly Dictionary<(EditorialWorkflowStage From, string Action), EditorialWorkflowStage>
        _transitions = new()
    {
        [(EditorialWorkflowStage.Draft, "Submit")] = EditorialWorkflowStage.Review,
        [(EditorialWorkflowStage.Review, "Approve")] = EditorialWorkflowStage.Translation,
        [(EditorialWorkflowStage.Review, "Reject")] = EditorialWorkflowStage.Rejected,
        [(EditorialWorkflowStage.Translation, "TranslationComplete")] = EditorialWorkflowStage.Approved,
        [(EditorialWorkflowStage.Approved, "Publish")] = EditorialWorkflowStage.Published,
        [(EditorialWorkflowStage.Rejected, "Revise")] = EditorialWorkflowStage.Draft,
        [(EditorialWorkflowStage.Published, "Unpublish")] = EditorialWorkflowStage.Draft,
    };

    /// <summary>
    /// Returns the target stage if the transition is valid, or null if not.
    /// </summary>
    public static EditorialWorkflowStage? TryGetTarget(
        EditorialWorkflowStage currentStage, string action)
        => _transitions.TryGetValue((currentStage, action), out var target)
            ? target
            : null;

    /// <summary>
    /// Returns all actions available from the given stage.
    /// </summary>
    public static IReadOnlyList<string> GetAvailableActions(
        EditorialWorkflowStage currentStage)
        => _transitions.Keys
            .Where(k => k.From == currentStage)
            .Select(k => k.Action)
            .ToList();
}

3. Gate Evaluator

// Generated: EditorialWorkflowGates.g.cs
public class EditorialWorkflowGateEvaluator
{
    private readonly IUserContext _userContext;
    private readonly IApprovalRepository _approvals;

    public EditorialWorkflowGateEvaluator(
        IUserContext userContext, IApprovalRepository approvals)
    {
        _userContext = userContext;
        _approvals = approvals;
    }

    /// <summary>
    /// Evaluates all gates for a given transition. Returns Success if all
    /// gates pass, or Failure with the first failing gate's reason.
    /// </summary>
    public async Task<Result> EvaluateAsync(
        string action, Guid entityId, CancellationToken ct = default)
    {
        return action switch
        {
            "Submit" => EvaluateRoleGate("Author"),
            "Approve" => Result.Aggregate(
                EvaluateRoleGate("Editor"),
                await EvaluateApprovalGate(entityId, minApprovers: 2, ct)),
            "Reject" => EvaluateRoleGate("Editor"),
            "Publish" => EvaluateRoleGate("Publisher"),
            "Unpublish" => EvaluateRoleGate("Publisher"),
            _ => Result.Success() // No gates on this transition
        };
    }

    private Result EvaluateRoleGate(string requiredRole)
        => _userContext.IsInRole(requiredRole)
            ? Result.Success()
            : Result.Failure($"User must have role '{requiredRole}'");

    private async Task<Result> EvaluateApprovalGate(
        Guid entityId, int minApprovers, CancellationToken ct)
    {
        var approvalCount = await _approvals.GetApprovalCountAsync(entityId, ct);
        return approvalCount >= minApprovers
            ? Result.Success()
            : Result.Failure(
                $"Requires {minApprovers} approvals, has {approvalCount}");
    }
}

4. Workflow Engine

// Generated: EditorialWorkflowEngine.g.cs
public class EditorialWorkflowEngine
{
    private readonly EditorialWorkflowGateEvaluator _gates;
    private readonly IWorkflowStateRepository _stateRepo;
    private readonly IDomainEventPublisher _events;

    public EditorialWorkflowEngine(
        EditorialWorkflowGateEvaluator gates,
        IWorkflowStateRepository stateRepo,
        IDomainEventPublisher events)
    {
        _gates = gates;
        _stateRepo = stateRepo;
        _events = events;
    }

    /// <summary>
    /// Attempts to execute a transition. Validates the transition exists,
    /// evaluates all gates, updates the state, and publishes domain events.
    /// </summary>
    public async Task<Result> TransitionAsync(
        Guid entityId, string action, CancellationToken ct = default)
    {
        // 1. Load current state
        var state = await _stateRepo.GetCurrentStageAsync(entityId, ct);

        // 2. Validate transition exists
        var target = EditorialWorkflowTransitions.TryGetTarget(state, action);
        if (target is null)
            return Result.Failure(
                $"No transition '{action}' from stage '{state}'");

        // 3. Evaluate gates
        var gateResult = await _gates.EvaluateAsync(action, entityId, ct);
        if (!gateResult.IsSuccess)
            return gateResult;

        // 4. Update state
        var previousStage = state;
        await _stateRepo.SetStageAsync(entityId, target.Value, ct);

        // 5. Publish domain events
        await _events.PublishAsync(new WorkflowTransitionedEvent(
            EntityId: entityId,
            WorkflowName: "Editorial",
            Action: action,
            FromStage: previousStage.ToString(),
            ToStage: target.Value.ToString(),
            TransitionedAt: DateTimeOffset.UtcNow,
            TransitionedBy: _gates._userContext.UserId));

        // 6. Check if stage completed
        await _events.PublishAsync(new StageCompletedEvent(
            EntityId: entityId,
            WorkflowName: "Editorial",
            Stage: target.Value.ToString()));

        return Result.Success();
    }
}

5. Domain Events

// Generated: EditorialWorkflowEvents.g.cs

/// <summary>
/// Published whenever an entity transitions between workflow stages.
/// Consumed by audit logs, notification services, and integrations.
/// </summary>
public record WorkflowTransitionedEvent(
    Guid EntityId,
    string WorkflowName,
    string Action,
    string FromStage,
    string ToStage,
    DateTimeOffset TransitionedAt,
    string TransitionedBy) : IDomainEvent;

/// <summary>
/// Published when a stage is completed. For [ForEachLocale] stages,
/// this fires only when all locales have completed.
/// </summary>
public record StageCompletedEvent(
    Guid EntityId,
    string WorkflowName,
    string Stage) : IDomainEvent;

/// <summary>
/// Published when a scheduled transition is executed by the
/// background service.
/// </summary>
public record ScheduledTransitionExecutedEvent(
    Guid EntityId,
    string WorkflowName,
    string FromStage,
    string ToStage,
    DateTimeOffset ScheduledFor,
    DateTimeOffset ExecutedAt) : IDomainEvent;

Per-Locale Progress Tracking

The [ForEachLocale] attribute on the "Translation" stage generates a locale tracking system. Each configured locale is tracked independently. The stage is only considered complete when all locales have reached completion.

Diagram

Generated Locale Tracker

// Generated: EditorialLocaleTracker.g.cs
public class EditorialLocaleTracker
{
    private readonly ILocaleProgressRepository _localeRepo;
    private readonly ILocaleConfiguration _localeConfig;

    public EditorialLocaleTracker(
        ILocaleProgressRepository localeRepo,
        ILocaleConfiguration localeConfig)
    {
        _localeRepo = localeRepo;
        _localeConfig = localeConfig;
    }

    /// <summary>
    /// Marks a specific locale as complete for the Translation stage.
    /// Returns true if all locales are now complete.
    /// </summary>
    public async Task<bool> MarkLocaleCompleteAsync(
        Guid entityId, string locale, CancellationToken ct = default)
    {
        await _localeRepo.SetLocaleStatusAsync(
            entityId, "Translation", locale, LocaleStatus.Complete, ct);

        var allLocales = _localeConfig.GetConfiguredLocales();
        var completedLocales = await _localeRepo.GetCompletedLocalesAsync(
            entityId, "Translation", ct);

        return allLocales.All(l => completedLocales.Contains(l));
    }

    /// <summary>
    /// Returns the progress summary for the Translation stage.
    /// </summary>
    public async Task<LocaleProgressSummary> GetProgressAsync(
        Guid entityId, CancellationToken ct = default)
    {
        var allLocales = _localeConfig.GetConfiguredLocales();
        var statuses = await _localeRepo.GetAllLocaleStatusesAsync(
            entityId, "Translation", ct);

        return new LocaleProgressSummary(
            TotalLocales: allLocales.Count,
            CompletedLocales: statuses.Count(s => s.Status == LocaleStatus.Complete),
            InProgressLocales: statuses.Count(s => s.Status == LocaleStatus.InProgress),
            PendingLocales: allLocales.Count - statuses.Count,
            LocaleDetails: statuses.ToList());
    }
}

Scheduled Transitions

The [ScheduledTransition("Approved", "Published", DateProperty = "PublishDate")] generates a background service that polls for entities in the "Approved" stage whose PublishDate has passed.

// Generated: EditorialScheduledTransitionService.g.cs
public class EditorialScheduledTransitionService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<EditorialScheduledTransitionService> _logger;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var stateRepo = scope.ServiceProvider
                .GetRequiredService<IWorkflowStateRepository>();
            var engine = scope.ServiceProvider
                .GetRequiredService<EditorialWorkflowEngine>();
            var events = scope.ServiceProvider
                .GetRequiredService<IDomainEventPublisher>();

            // Find all entities in "Approved" with PublishDate <= now
            var pendingEntities = await stateRepo
                .GetEntitiesInStageWithScheduledDateAsync(
                    "Editorial",
                    EditorialWorkflowStage.Approved,
                    "PublishDate",
                    DateTimeOffset.UtcNow,
                    ct);

            foreach (var entityId in pendingEntities)
            {
                var result = await engine.TransitionAsync(
                    entityId, "Publish", ct);

                if (result.IsSuccess)
                {
                    await events.PublishAsync(new ScheduledTransitionExecutedEvent(
                        EntityId: entityId,
                        WorkflowName: "Editorial",
                        FromStage: "Approved",
                        ToStage: "Published",
                        ScheduledFor: DateTimeOffset.UtcNow,
                        ExecutedAt: DateTimeOffset.UtcNow));

                    _logger.LogInformation(
                        "Scheduled publish executed for entity {EntityId}", entityId);
                }
                else
                {
                    _logger.LogWarning(
                        "Scheduled publish failed for entity {EntityId}: {Reason}",
                        entityId, result.Reason);
                }
            }

            await Task.Delay(TimeSpan.FromMinutes(1), ct);
        }
    }
}

Integration with Requirements DSL

Workflow gates can check requirement compliance. A gate of type RequiresCompliance blocks a transition until the linked requirements have passing acceptance criteria. This integrates the Workflow DSL with the Requirements DSL (Part IX).

// Custom gate: blocks "Publish" until all ACs pass for the linked feature
[Gate("RequiresCompliance", Transition = "Publish",
    GateType = "RequiresCompliance")]

The generated gate evaluator for a compliance gate:

// Generated: compliance gate evaluation (integrated into EditorialWorkflowGateEvaluator)
private async Task<Result> EvaluateComplianceGate(
    Guid entityId, CancellationToken ct)
{
    var linkedFeatures = await _requirementsRepo
        .GetLinkedFeaturesAsync(entityId, ct);

    foreach (var feature in linkedFeatures)
    {
        var compliance = await _traceabilityMatrix
            .GetFeatureComplianceAsync(feature.FeatureType, ct);

        if (compliance.Status != ComplianceStatus.FullyCovered)
            return Result.Failure(
                $"Feature '{feature.Name}' is not fully covered: " +
                $"{compliance.PassedCriteria}/{compliance.TotalCriteria} ACs passing");
    }

    return Result.Success();
}

This means a blog post cannot be published until the features it implements have all their acceptance criteria tested and passing. The workflow enforces the requirements chain at the editorial level.


Compile-Time Validation (Stage 1)

Diagnostic Severity Rule
WFL001 Error Workflow has fewer than two stages
WFL002 Error Workflow has no stage marked IsInitial = true
WFL003 Error Workflow has multiple stages marked IsInitial = true
WFL004 Error Transition references a stage not declared in the workflow
WFL005 Error Transition From and To are the same stage
WFL006 Error [HasWorkflow] references a name not matching any [Workflow]
WFL007 Error [RequiresRole] references a transition not declared in the workflow
WFL008 Error [ForEachLocale] references a stage not declared in the workflow
WFL009 Error [ScheduledTransition] DateProperty does not exist on the entity
WFL010 Warning Stage is unreachable (no transition leads to it except initial)
WFL011 Warning Stage has no outgoing transitions and is not marked IsFinal

Summary

The Workflow DSL transforms declarative attribute definitions into a complete editorial pipeline:

Generated Artifact Approx. Lines Stage
Stage enum 15 2
Transition validator (lookup dictionary + methods) 60 2
Gate evaluator (role, approval, compliance checks) 120 2-3
Workflow engine (transition orchestration) 100 3
Domain events (Transitioned, StageCompleted, Scheduled) 40 2
Locale tracker (per-locale progress) 80 3
Scheduled transition background service 70 3
EF Core configuration for workflow state 40 2-3
Total per workflow ~500-600

The developer writes ~40 lines of workflow attributes. The compiler generates ~500-600 lines of state machine logic, gate evaluation, locale tracking, scheduled publishing, and domain events. The workflow is enforced at runtime: invalid transitions are rejected, gates block unauthorized users, and the Requirements DSL integration ensures content quality before publication.