Cross-Cutting CMS Concerns and the Workflow DSL (M2)
Every production CMF needs cross-cutting content management concerns. Some are simple Content Parts; the publishing workflow is a full M2 DSL in itself.
The Workflow DSL -- Editorial Pipeline as a State Machine
Content publishing in a real organization is not a toggle between "draft" and "published." It is a multi-stage pipeline involving fact-checkers, translators, quality reviewers, and editors -- each with their own gates and approvals. The Workflow DSL models this as a compile-time state machine.
[MetaConcept("Workflow")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class WorkflowAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
public WorkflowAttribute(string name) => Name = name;
}
[MetaConcept("Stage")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class StageAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaProperty("Initial")]
public bool Initial { get; set; }
[MetaProperty("Terminal")]
public bool Terminal { get; set; }
public StageAttribute(string name) => Name = name;
}
[MetaConcept("Transition")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class TransitionAttribute : Attribute
{
[MetaProperty("From", Required = true)]
public string From { get; }
[MetaProperty("To", Required = true)]
public string To { get; }
[MetaProperty("Name")]
public string? Name { get; set; }
[MetaProperty("RequiresComment")]
public bool RequiresComment { get; set; }
public TransitionAttribute(string from, string to)
{
From = from;
To = to;
}
}
[MetaConcept("Gate")]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public sealed class GateAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaProperty("Expression", Required = true)]
public string Expression { get; }
[MetaProperty("Message")]
public string? Message { get; set; }
public GateAttribute(string name, string expression)
{
Name = name;
Expression = expression;
}
}
[MetaConcept("RequiresRole")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class RequiresRoleAttribute : Attribute
{
[MetaProperty("Role", Required = true)]
public string Role { get; }
public RequiresRoleAttribute(string role) => Role = role;
}
[MetaConcept("ForEachLocale")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class ForEachLocaleAttribute : Attribute { }
[MetaConcept("ScheduledTransition")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class ScheduledTransitionAttribute : Attribute { }
[MetaConcept("HasWorkflow")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class HasWorkflowAttribute : Attribute
{
[MetaReference("Workflow", "Workflow", Multiplicity = Multiplicity.One)]
public string Workflow { get; }
public HasWorkflowAttribute(string workflow) => Workflow = workflow;
}[MetaConcept("Workflow")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class WorkflowAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
public WorkflowAttribute(string name) => Name = name;
}
[MetaConcept("Stage")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class StageAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaProperty("Initial")]
public bool Initial { get; set; }
[MetaProperty("Terminal")]
public bool Terminal { get; set; }
public StageAttribute(string name) => Name = name;
}
[MetaConcept("Transition")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class TransitionAttribute : Attribute
{
[MetaProperty("From", Required = true)]
public string From { get; }
[MetaProperty("To", Required = true)]
public string To { get; }
[MetaProperty("Name")]
public string? Name { get; set; }
[MetaProperty("RequiresComment")]
public bool RequiresComment { get; set; }
public TransitionAttribute(string from, string to)
{
From = from;
To = to;
}
}
[MetaConcept("Gate")]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public sealed class GateAttribute : Attribute
{
[MetaProperty("Name", Required = true)]
public string Name { get; }
[MetaProperty("Expression", Required = true)]
public string Expression { get; }
[MetaProperty("Message")]
public string? Message { get; set; }
public GateAttribute(string name, string expression)
{
Name = name;
Expression = expression;
}
}
[MetaConcept("RequiresRole")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class RequiresRoleAttribute : Attribute
{
[MetaProperty("Role", Required = true)]
public string Role { get; }
public RequiresRoleAttribute(string role) => Role = role;
}
[MetaConcept("ForEachLocale")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class ForEachLocaleAttribute : Attribute { }
[MetaConcept("ScheduledTransition")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class ScheduledTransitionAttribute : Attribute { }
[MetaConcept("HasWorkflow")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class HasWorkflowAttribute : Attribute
{
[MetaReference("Workflow", "Workflow", Multiplicity = Multiplicity.One)]
public string Workflow { get; }
public HasWorkflowAttribute(string workflow) => Workflow = workflow;
}A complete editorial workflow:
[Workflow("Editorial")]
[Transition("Draft", "FactCheck")]
[Transition("FactCheck", "Translation")]
[Transition("Translation", "QualityReview")]
[Transition("QualityReview", "Published")]
[Transition("QualityReview", "Draft", Name = "Reject", RequiresComment = true)]
[Transition("Published", "Draft", Name = "Unpublish")]
public partial class EditorialWorkflow
{
[Stage("Draft", Initial = true)]
public partial WorkflowStage Draft { get; }
[Stage("FactCheck")]
[RequiresRole("FactChecker")]
[Gate("AllSourcesCited", "Sources.Count > 0",
Message = "All claims must cite sources")]
public partial WorkflowStage FactCheck { get; }
[Stage("Translation")]
[RequiresRole("Translator")]
[ForEachLocale] // runs independently per locale
public partial WorkflowStage Translation { get; }
[Stage("QualityReview")]
[RequiresRole("Editor")]
[Gate("SpellCheck", "SpellCheckScore == 100",
Message = "Must pass spell check")]
[Gate("SeoReady", "SeoScore >= 70",
Message = "SEO score must be at least 70")]
[Gate("AllLinksValid", "BrokenLinkCount == 0",
Message = "All links must be valid")]
public partial WorkflowStage QualityReview { get; }
[Stage("Published", Terminal = true)]
[ScheduledTransition] // can be scheduled for a future date
public partial WorkflowStage Published { get; }
}
// Attach to a content type:
[AggregateRoot("Article", BoundedContext = "Content")]
[HasWorkflow("Editorial")]
[HasPart("Routable")]
[HasPart("Seoable")]
[HasPart("Versionable")]
public partial class Article
{
[EntityId] public partial ArticleId Id { get; }
[Property("Title", Required = true)] public partial string Title { get; }
[StreamField("Body", StreamBlock = "PageContent")]
public partial PageContentStream Body { get; }
}[Workflow("Editorial")]
[Transition("Draft", "FactCheck")]
[Transition("FactCheck", "Translation")]
[Transition("Translation", "QualityReview")]
[Transition("QualityReview", "Published")]
[Transition("QualityReview", "Draft", Name = "Reject", RequiresComment = true)]
[Transition("Published", "Draft", Name = "Unpublish")]
public partial class EditorialWorkflow
{
[Stage("Draft", Initial = true)]
public partial WorkflowStage Draft { get; }
[Stage("FactCheck")]
[RequiresRole("FactChecker")]
[Gate("AllSourcesCited", "Sources.Count > 0",
Message = "All claims must cite sources")]
public partial WorkflowStage FactCheck { get; }
[Stage("Translation")]
[RequiresRole("Translator")]
[ForEachLocale] // runs independently per locale
public partial WorkflowStage Translation { get; }
[Stage("QualityReview")]
[RequiresRole("Editor")]
[Gate("SpellCheck", "SpellCheckScore == 100",
Message = "Must pass spell check")]
[Gate("SeoReady", "SeoScore >= 70",
Message = "SEO score must be at least 70")]
[Gate("AllLinksValid", "BrokenLinkCount == 0",
Message = "All links must be valid")]
public partial WorkflowStage QualityReview { get; }
[Stage("Published", Terminal = true)]
[ScheduledTransition] // can be scheduled for a future date
public partial WorkflowStage Published { get; }
}
// Attach to a content type:
[AggregateRoot("Article", BoundedContext = "Content")]
[HasWorkflow("Editorial")]
[HasPart("Routable")]
[HasPart("Seoable")]
[HasPart("Versionable")]
public partial class Article
{
[EntityId] public partial ArticleId Id { get; }
[Property("Title", Required = true)] public partial string Title { get; }
[StreamField("Body", StreamBlock = "PageContent")]
public partial PageContentStream Body { get; }
}The generator produces:
- State machine implementation -- sealed states, typed transitions, guard validation
- Transition guards -- check gates (quality expressions) and roles before allowing a transition
- Per-locale tracking -- for the Translation stage, a separate status per locale
- Scheduled publishing -- background job that transitions to Published at a configured datetime
- Audit trail -- who transitioned what, when, with what comment, creating a new revision each time
- Admin UI components -- workflow status badge, transition buttons, gate checklist, revision timeline
Versionable -- Content Versioning
A Content Part that adds revision tracking to any aggregate:
[ContentPart("Versionable")]
public partial class VersionablePart
{
[PartField("RevisionNumber")] public partial int RevisionNumber { get; }
[PartField("RevisionComment")] public partial string? RevisionComment { get; }
}[ContentPart("Versionable")]
public partial class VersionablePart
{
[PartField("RevisionNumber")] public partial int RevisionNumber { get; }
[PartField("RevisionComment")] public partial string? RevisionComment { get; }
}[HasPart("Versionable")] on an aggregate generates:
- A revision table:
ArticleRevisionwith a full snapshot of the aggregate at each save - A diff view: compare any two revisions
- A rollback command:
RollbackArticleCommandthat restores a previous revision - Integration with the Workflow DSL: every
[Transition]creates a new revision automatically
Localizable -- Multi-Language Content
[ContentPart("Localizable")]
public partial class LocalizablePart
{
[PartField("Locale", Required = true)] public partial string Locale { get; }
}[ContentPart("Localizable")]
public partial class LocalizablePart
{
[PartField("Locale", Required = true)] public partial string Locale { get; }
}[HasPart("Localizable")] generates:
- A per-locale content table:
Article_en,Article_fr,Article_de(or a single table with locale column, depending on configuration) - A locale resolver middleware: determines the current locale from URL, header, or cookie
- A fallback chain:
fr-FR → fr → en-- if content is not available in the requested locale, fall back gracefully - Integration with the Workflow DSL: the Translation stage tracks per-locale completion
Taxonomy -- Hierarchical Classification
A built-in aggregate with tree structure:
// Ships with Cmf.Content.Lib -- no user-defined DSL needed
[AggregateRoot("Taxonomy", BoundedContext = "Content")]
public partial class Taxonomy
{
[EntityId] public partial TaxonomyId Id { get; }
[Property("Name", Required = true)] public partial string Name { get; }
[Property("Slug", Required = true)] public partial string Slug { get; }
[Composition] public partial IReadOnlyList<TaxonomyNode> Nodes { get; }
}
[Entity("TaxonomyNode")]
public partial class TaxonomyNode
{
[EntityId] public partial TaxonomyNodeId Id { get; }
[Property("Label", Required = true)] public partial string Label { get; }
[Property("Slug", Required = true)] public partial string Slug { get; }
[Aggregation] public partial TaxonomyNode? Parent { get; }
[Aggregation] public partial IReadOnlyList<TaxonomyNode> Children { get; }
}// Ships with Cmf.Content.Lib -- no user-defined DSL needed
[AggregateRoot("Taxonomy", BoundedContext = "Content")]
public partial class Taxonomy
{
[EntityId] public partial TaxonomyId Id { get; }
[Property("Name", Required = true)] public partial string Name { get; }
[Property("Slug", Required = true)] public partial string Slug { get; }
[Composition] public partial IReadOnlyList<TaxonomyNode> Nodes { get; }
}
[Entity("TaxonomyNode")]
public partial class TaxonomyNode
{
[EntityId] public partial TaxonomyNodeId Id { get; }
[Property("Label", Required = true)] public partial string Label { get; }
[Property("Slug", Required = true)] public partial string Slug { get; }
[Aggregation] public partial TaxonomyNode? Parent { get; }
[Aggregation] public partial IReadOnlyList<TaxonomyNode> Children { get; }
}Any content type with [HasPart("Taggable")] gets a many-to-many relationship with taxonomy nodes, enabling faceted navigation and classification.
Searchable -- Full-Text Search
[ContentPart("Searchable")]
public partial class SearchablePart { }
[MetaConcept("SearchField")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class SearchFieldAttribute : Attribute
{
[MetaProperty("Boost")]
public float Boost { get; set; } = 1.0f;
[MetaProperty("Analyzer")]
public string? Analyzer { get; set; }
}[ContentPart("Searchable")]
public partial class SearchablePart { }
[MetaConcept("SearchField")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class SearchFieldAttribute : Attribute
{
[MetaProperty("Boost")]
public float Boost { get; set; } = 1.0f;
[MetaProperty("Analyzer")]
public string? Analyzer { get; set; }
}Usage:
[AggregateRoot("Product", BoundedContext = "Catalog")]
[HasPart("Searchable")]
public partial class Product
{
[Property("Name", Required = true)]
[SearchField(Boost = 2.0f)]
public partial string Name { get; }
[Property("Description")]
[SearchField(Boost = 1.0f, Analyzer = "standard")]
public partial string? Description { get; }
}[AggregateRoot("Product", BoundedContext = "Catalog")]
[HasPart("Searchable")]
public partial class Product
{
[Property("Name", Required = true)]
[SearchField(Boost = 2.0f)]
public partial string Name { get; }
[Property("Description")]
[SearchField(Boost = 1.0f, Analyzer = "standard")]
public partial string? Description { get; }
}Generates: search index configuration, a SearchProductsQuery handler, and a search result DTO. The search backend is pluggable: Lucene.NET, Elasticsearch, or Azure Cognitive Search.
MediaRef -- Media Management
// MediaRef is a built-in value object shipped with Cmf.Content.Lib
[ValueObject("MediaRef")]
public partial class MediaRef
{
[ValueComponent("AssetId", "Guid", Required = true)]
public partial Guid AssetId { get; }
[ValueComponent("Alt", "string")]
public partial string? Alt { get; }
[ValueComponent("Title", "string")]
public partial string? Title { get; }
}// MediaRef is a built-in value object shipped with Cmf.Content.Lib
[ValueObject("MediaRef")]
public partial class MediaRef
{
[ValueComponent("AssetId", "Guid", Required = true)]
public partial Guid AssetId { get; }
[ValueComponent("Alt", "string")]
public partial string? Alt { get; }
[ValueComponent("Title", "string")]
public partial string? Title { get; }
}The CMF ships a built-in Media aggregate: asset library with image processing pipeline (resize, crop, format conversion), CDN URL generation, and responsive image srcset support. MediaRef is used in entity properties, block fields, and content part fields.