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

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

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

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
Diagram
The Editorial workflow as a state machine — each transition is gated by a role and a set of quality checks, and the generator builds the state machine, the guards, the audit trail and the admin widgets from this very declaration.

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

[HasPart("Versionable")] on an aggregate generates:

  • A revision table: ArticleRevision with a full snapshot of the aggregate at each save
  • A diff view: compare any two revisions
  • A rollback command: RollbackArticleCommand that 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; }
}

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

Any content type with [HasPart("Taggable")] gets a many-to-many relationship with taxonomy nodes, enabling faceted navigation and classification.

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

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

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.

⬇ Download