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;
}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;
}/// <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;
}/// <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;
}/// <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;
}/// <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;
}
}/// <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;
}
}/// <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;
}/// <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;
}
}/// <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 { }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; }
}[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
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
}// 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();
}// 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}");
}
}// 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();
}
}// 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;// 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.
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());
}
}// 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);
}
}
}// 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")]// 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();
}// 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.