Part V: Content DSL -- Parts & Blocks
Overview
The Content DSL introduces two composition strategies for enriching domain entities with content capabilities:
- Content Parts (horizontal composition): cross-cutting concerns added to entities. A
BlogPostaggregate gains SEO metadata, URL routing, and tagging by attaching parts -- without modifying the aggregate's core domain model. - Content Blocks (vertical composition): structured field types that define how a piece of content is shaped. A
HeroBlockcontains a heading, subheading, image, and call-to-action. Blocks compose into StreamFields -- ordered, type-discriminated sequences stored as JSON.
The developer writes attributed partial classes. The source generator produces JSON serialization, admin field editors, API schema types, and Blazor rendering components.
Two Axes of Composition
Parts add capabilities to an entity (it can be routed, it can be tagged). Blocks define structure within a field (a hero section has a heading and an image). Both are declared with attributes and processed by the Content DSL source generator.
Content Parts: Horizontal Composition
Core Attributes
ContentPart
namespace Cmf.Content.Lib;
/// <summary>
/// Declares a reusable content part that can be attached to any entity
/// via [HasPart]. Parts add cross-cutting content capabilities without
/// modifying the entity's domain model.
/// </summary>
[MetaConcept("ContentPart")]
[MetaConstraint("MustHaveAtLeastOneField",
"Properties.Any(p => p.IsAnnotatedWith('PartField'))",
Message = "Content part must have at least one [PartField] property")]
public sealed class ContentPartAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("Description", "string")]
public string? Description { get; set; }
}namespace Cmf.Content.Lib;
/// <summary>
/// Declares a reusable content part that can be attached to any entity
/// via [HasPart]. Parts add cross-cutting content capabilities without
/// modifying the entity's domain model.
/// </summary>
[MetaConcept("ContentPart")]
[MetaConstraint("MustHaveAtLeastOneField",
"Properties.Any(p => p.IsAnnotatedWith('PartField'))",
Message = "Content part must have at least one [PartField] property")]
public sealed class ContentPartAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("Description", "string")]
public string? Description { get; set; }
}PartField
/// <summary>
/// Declares a field within a content part. Fields are persisted,
/// editable in admin forms, and exposed in API schemas.
/// </summary>
[MetaConcept("PartField")]
public sealed class PartFieldAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("DisplayName", "string")]
public string? DisplayName { get; set; }
[MetaProperty("Required", "bool")]
public bool Required { get; set; } = false;
[MetaProperty("MaxLength", "int")]
public int? MaxLength { get; set; }
[MetaProperty("HelpText", "string")]
public string? HelpText { get; set; }
}/// <summary>
/// Declares a field within a content part. Fields are persisted,
/// editable in admin forms, and exposed in API schemas.
/// </summary>
[MetaConcept("PartField")]
public sealed class PartFieldAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("DisplayName", "string")]
public string? DisplayName { get; set; }
[MetaProperty("Required", "bool")]
public bool Required { get; set; } = false;
[MetaProperty("MaxLength", "int")]
public int? MaxLength { get; set; }
[MetaProperty("HelpText", "string")]
public string? HelpText { get; set; }
}HasPart
/// <summary>
/// Attaches a content part to an entity. The entity gains all fields
/// from the part without declaring them explicitly.
/// </summary>
[MetaConcept("HasPart")]
[MetaReference("Part", "ContentPart", Multiplicity = "1")]
[MetaConstraint("TargetMustBeContentPart",
"ReferencedType.IsAnnotatedWith('ContentPart')",
Message = "HasPart target must be a [ContentPart] type")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class HasPartAttribute : Attribute
{
[MetaProperty("PartType", "Type", Required = true)]
public Type PartType { get; }
public HasPartAttribute(Type partType) => PartType = partType;
}/// <summary>
/// Attaches a content part to an entity. The entity gains all fields
/// from the part without declaring them explicitly.
/// </summary>
[MetaConcept("HasPart")]
[MetaReference("Part", "ContentPart", Multiplicity = "1")]
[MetaConstraint("TargetMustBeContentPart",
"ReferencedType.IsAnnotatedWith('ContentPart')",
Message = "HasPart target must be a [ContentPart] type")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class HasPartAttribute : Attribute
{
[MetaProperty("PartType", "Type", Required = true)]
public Type PartType { get; }
public HasPartAttribute(Type partType) => PartType = partType;
}Built-In Content Parts
The CMF provides five standard parts. Developers can define custom parts using the same attribute pattern.
RoutablePart
namespace Cmf.Content.Lib.Parts;
/// <summary>
/// Adds URL routing capability to an entity. Generates a slug from
/// a source property and computes the full URL path.
/// </summary>
[ContentPart("Routable", Description = "URL routing with slugs")]
public partial class RoutablePart
{
[PartField("Slug", Required = true, MaxLength = 200,
HelpText = "URL-friendly identifier, auto-generated from title")]
public partial string Slug { get; }
[PartField("UrlPath", DisplayName = "URL Path",
HelpText = "Full path including parent slugs")]
public partial string UrlPath { get; }
[PartField("IsCanonical", DisplayName = "Canonical URL")]
public partial bool IsCanonical { get; }
}namespace Cmf.Content.Lib.Parts;
/// <summary>
/// Adds URL routing capability to an entity. Generates a slug from
/// a source property and computes the full URL path.
/// </summary>
[ContentPart("Routable", Description = "URL routing with slugs")]
public partial class RoutablePart
{
[PartField("Slug", Required = true, MaxLength = 200,
HelpText = "URL-friendly identifier, auto-generated from title")]
public partial string Slug { get; }
[PartField("UrlPath", DisplayName = "URL Path",
HelpText = "Full path including parent slugs")]
public partial string UrlPath { get; }
[PartField("IsCanonical", DisplayName = "Canonical URL")]
public partial bool IsCanonical { get; }
}SeoablePart
/// <summary>
/// Adds SEO metadata to an entity. Fields map to HTML meta tags.
/// </summary>
[ContentPart("Seoable", Description = "SEO metadata (title, description, OG tags)")]
public partial class SeoablePart
{
[PartField("MetaTitle", DisplayName = "Meta Title",
MaxLength = 70, HelpText = "Browser tab title and search result heading")]
public partial string? MetaTitle { get; }
[PartField("MetaDescription", DisplayName = "Meta Description",
MaxLength = 160, HelpText = "Search result snippet text")]
public partial string? MetaDescription { get; }
[PartField("OgImage", DisplayName = "Open Graph Image",
HelpText = "Social sharing image URL")]
public partial string? OgImage { get; }
[PartField("NoIndex", DisplayName = "No Index",
HelpText = "Prevent search engines from indexing this page")]
public partial bool NoIndex { get; }
}/// <summary>
/// Adds SEO metadata to an entity. Fields map to HTML meta tags.
/// </summary>
[ContentPart("Seoable", Description = "SEO metadata (title, description, OG tags)")]
public partial class SeoablePart
{
[PartField("MetaTitle", DisplayName = "Meta Title",
MaxLength = 70, HelpText = "Browser tab title and search result heading")]
public partial string? MetaTitle { get; }
[PartField("MetaDescription", DisplayName = "Meta Description",
MaxLength = 160, HelpText = "Search result snippet text")]
public partial string? MetaDescription { get; }
[PartField("OgImage", DisplayName = "Open Graph Image",
HelpText = "Social sharing image URL")]
public partial string? OgImage { get; }
[PartField("NoIndex", DisplayName = "No Index",
HelpText = "Prevent search engines from indexing this page")]
public partial bool NoIndex { get; }
}TaggablePart
/// <summary>
/// Adds tagging capability. Tags are stored as a string collection
/// and support taxonomy-based filtering.
/// </summary>
[ContentPart("Taggable", Description = "Tag collection for categorization")]
public partial class TaggablePart
{
[PartField("Tags", DisplayName = "Tags",
HelpText = "Comma-separated tags for categorization")]
public partial IReadOnlyList<string> Tags { get; }
}/// <summary>
/// Adds tagging capability. Tags are stored as a string collection
/// and support taxonomy-based filtering.
/// </summary>
[ContentPart("Taggable", Description = "Tag collection for categorization")]
public partial class TaggablePart
{
[PartField("Tags", DisplayName = "Tags",
HelpText = "Comma-separated tags for categorization")]
public partial IReadOnlyList<string> Tags { get; }
}AuditablePart
/// <summary>
/// Adds audit trail fields. Automatically populated by the framework
/// on create and update operations.
/// </summary>
[ContentPart("Auditable", Description = "Created/modified tracking")]
public partial class AuditablePart
{
[PartField("CreatedAt", DisplayName = "Created")]
public partial DateTimeOffset CreatedAt { get; }
[PartField("CreatedBy", DisplayName = "Created By")]
public partial string CreatedBy { get; }
[PartField("ModifiedAt", DisplayName = "Last Modified")]
public partial DateTimeOffset? ModifiedAt { get; }
[PartField("ModifiedBy", DisplayName = "Modified By")]
public partial string? ModifiedBy { get; }
}/// <summary>
/// Adds audit trail fields. Automatically populated by the framework
/// on create and update operations.
/// </summary>
[ContentPart("Auditable", Description = "Created/modified tracking")]
public partial class AuditablePart
{
[PartField("CreatedAt", DisplayName = "Created")]
public partial DateTimeOffset CreatedAt { get; }
[PartField("CreatedBy", DisplayName = "Created By")]
public partial string CreatedBy { get; }
[PartField("ModifiedAt", DisplayName = "Last Modified")]
public partial DateTimeOffset? ModifiedAt { get; }
[PartField("ModifiedBy", DisplayName = "Modified By")]
public partial string? ModifiedBy { get; }
}VersionablePart
/// <summary>
/// Adds draft/publish versioning. Entities with this part maintain
/// a current (published) version and an optional draft version.
/// Integrates with the Workflow DSL (Part VIII) for editorial pipelines.
/// </summary>
[ContentPart("Versionable", Description = "Draft/publish versioning")]
public partial class VersionablePart
{
[PartField("VersionNumber", DisplayName = "Version")]
public partial int VersionNumber { get; }
[PartField("Status", DisplayName = "Publication Status")]
public partial VersionStatus Status { get; }
[PartField("PublishedAt", DisplayName = "Published")]
public partial DateTimeOffset? PublishedAt { get; }
[PartField("PublishedBy", DisplayName = "Published By")]
public partial string? PublishedBy { get; }
}
public enum VersionStatus { Draft, Published, Archived }/// <summary>
/// Adds draft/publish versioning. Entities with this part maintain
/// a current (published) version and an optional draft version.
/// Integrates with the Workflow DSL (Part VIII) for editorial pipelines.
/// </summary>
[ContentPart("Versionable", Description = "Draft/publish versioning")]
public partial class VersionablePart
{
[PartField("VersionNumber", DisplayName = "Version")]
public partial int VersionNumber { get; }
[PartField("Status", DisplayName = "Publication Status")]
public partial VersionStatus Status { get; }
[PartField("PublishedAt", DisplayName = "Published")]
public partial DateTimeOffset? PublishedAt { get; }
[PartField("PublishedBy", DisplayName = "Published By")]
public partial string? PublishedBy { get; }
}
public enum VersionStatus { Draft, Published, Archived }Content Blocks: Vertical Composition
Core Attributes
StructBlock
/// <summary>
/// Declares a structured content block with named fields.
/// Blocks are the building units of StreamFields.
/// Serialized as JSON objects with a type discriminator.
/// </summary>
[MetaConcept("StructBlock")]
[MetaConstraint("MustHaveAtLeastOneField",
"Properties.Any(p => p.IsAnnotatedWith('BlockField'))",
Message = "Struct block must have at least one [BlockField] property")]
public sealed class StructBlockAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("Description", "string")]
public string? Description { get; set; }
[MetaProperty("Icon", "string")]
public string? Icon { get; set; }
}/// <summary>
/// Declares a structured content block with named fields.
/// Blocks are the building units of StreamFields.
/// Serialized as JSON objects with a type discriminator.
/// </summary>
[MetaConcept("StructBlock")]
[MetaConstraint("MustHaveAtLeastOneField",
"Properties.Any(p => p.IsAnnotatedWith('BlockField'))",
Message = "Struct block must have at least one [BlockField] property")]
public sealed class StructBlockAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("Description", "string")]
public string? Description { get; set; }
[MetaProperty("Icon", "string")]
public string? Icon { get; set; }
}BlockField
/// <summary>
/// Declares a field within a content block. Similar to PartField
/// but scoped to block composition rather than part composition.
/// </summary>
[MetaConcept("BlockField")]
public sealed class BlockFieldAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("DisplayName", "string")]
public string? DisplayName { get; set; }
[MetaProperty("Required", "bool")]
public bool Required { get; set; } = false;
[MetaProperty("MaxLength", "int")]
public int? MaxLength { get; set; }
}/// <summary>
/// Declares a field within a content block. Similar to PartField
/// but scoped to block composition rather than part composition.
/// </summary>
[MetaConcept("BlockField")]
public sealed class BlockFieldAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("DisplayName", "string")]
public string? DisplayName { get; set; }
[MetaProperty("Required", "bool")]
public bool Required { get; set; } = false;
[MetaProperty("MaxLength", "int")]
public int? MaxLength { get; set; }
}ListBlock
/// <summary>
/// A block that contains an ordered list of a single child block type.
/// Example: a gallery block containing a list of ImageBlocks.
/// </summary>
[MetaConcept("ListBlock")]
[MetaReference("ChildBlock", "StructBlock", Multiplicity = "1")]
public sealed class ListBlockAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("ChildBlockType", "Type", Required = true)]
public Type ChildBlockType { get; set; }
[MetaProperty("MinItems", "int")]
public int MinItems { get; set; } = 0;
[MetaProperty("MaxItems", "int")]
public int? MaxItems { get; set; }
}/// <summary>
/// A block that contains an ordered list of a single child block type.
/// Example: a gallery block containing a list of ImageBlocks.
/// </summary>
[MetaConcept("ListBlock")]
[MetaReference("ChildBlock", "StructBlock", Multiplicity = "1")]
public sealed class ListBlockAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("ChildBlockType", "Type", Required = true)]
public Type ChildBlockType { get; set; }
[MetaProperty("MinItems", "int")]
public int MinItems { get; set; } = 0;
[MetaProperty("MaxItems", "int")]
public int? MaxItems { get; set; }
}StreamBlock
/// <summary>
/// A block that contains an ordered sequence of heterogeneous child blocks.
/// This is the recursive composition mechanism: a StreamBlock within a
/// StreamField allows arbitrary nesting of block types.
/// </summary>
[MetaConcept("StreamBlock")]
[MetaReference("AllowedBlocks", "StructBlock", Multiplicity = "1..*")]
public sealed class StreamBlockAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("AllowedBlockTypes", "Type[]", Required = true)]
public Type[] AllowedBlockTypes { get; set; }
}/// <summary>
/// A block that contains an ordered sequence of heterogeneous child blocks.
/// This is the recursive composition mechanism: a StreamBlock within a
/// StreamField allows arbitrary nesting of block types.
/// </summary>
[MetaConcept("StreamBlock")]
[MetaReference("AllowedBlocks", "StructBlock", Multiplicity = "1..*")]
public sealed class StreamBlockAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("AllowedBlockTypes", "Type[]", Required = true)]
public Type[] AllowedBlockTypes { get; set; }
}Built-In Block Types
HeroBlock
namespace Cmf.Content.Lib.Blocks;
/// <summary>
/// A full-width hero section typically used at the top of landing pages.
/// </summary>
[StructBlock("Hero", Description = "Hero banner with heading and CTA", Icon = "star")]
public partial class HeroBlock
{
[BlockField("Heading", Required = true, MaxLength = 120)]
public partial string Heading { get; }
[BlockField("Subheading", MaxLength = 250)]
public partial string? Subheading { get; }
[BlockField("BackgroundImage", DisplayName = "Background Image")]
public partial string? BackgroundImageUrl { get; }
[BlockField("CtaText", DisplayName = "Call-to-Action Text")]
public partial string? CtaText { get; }
[BlockField("CtaUrl", DisplayName = "Call-to-Action URL")]
public partial string? CtaUrl { get; }
}namespace Cmf.Content.Lib.Blocks;
/// <summary>
/// A full-width hero section typically used at the top of landing pages.
/// </summary>
[StructBlock("Hero", Description = "Hero banner with heading and CTA", Icon = "star")]
public partial class HeroBlock
{
[BlockField("Heading", Required = true, MaxLength = 120)]
public partial string Heading { get; }
[BlockField("Subheading", MaxLength = 250)]
public partial string? Subheading { get; }
[BlockField("BackgroundImage", DisplayName = "Background Image")]
public partial string? BackgroundImageUrl { get; }
[BlockField("CtaText", DisplayName = "Call-to-Action Text")]
public partial string? CtaText { get; }
[BlockField("CtaUrl", DisplayName = "Call-to-Action URL")]
public partial string? CtaUrl { get; }
}RichTextBlock
/// <summary>
/// A block of HTML rich text content, edited via a WYSIWYG editor
/// in the admin interface.
/// </summary>
[StructBlock("RichText", Description = "Rich text HTML content", Icon = "edit")]
public partial class RichTextBlock
{
[BlockField("Content", Required = true, DisplayName = "Content")]
public partial string HtmlContent { get; }
}/// <summary>
/// A block of HTML rich text content, edited via a WYSIWYG editor
/// in the admin interface.
/// </summary>
[StructBlock("RichText", Description = "Rich text HTML content", Icon = "edit")]
public partial class RichTextBlock
{
[BlockField("Content", Required = true, DisplayName = "Content")]
public partial string HtmlContent { get; }
}TestimonialBlock
/// <summary>
/// A customer testimonial or quote block with attribution.
/// </summary>
[StructBlock("Testimonial", Description = "Customer quote with attribution", Icon = "quote")]
public partial class TestimonialBlock
{
[BlockField("Quote", Required = true, MaxLength = 500)]
public partial string Quote { get; }
[BlockField("AuthorName", DisplayName = "Author", Required = true)]
public partial string AuthorName { get; }
[BlockField("AuthorTitle", DisplayName = "Author Title")]
public partial string? AuthorTitle { get; }
[BlockField("PhotoUrl", DisplayName = "Author Photo")]
public partial string? PhotoUrl { get; }
}/// <summary>
/// A customer testimonial or quote block with attribution.
/// </summary>
[StructBlock("Testimonial", Description = "Customer quote with attribution", Icon = "quote")]
public partial class TestimonialBlock
{
[BlockField("Quote", Required = true, MaxLength = 500)]
public partial string Quote { get; }
[BlockField("AuthorName", DisplayName = "Author", Required = true)]
public partial string AuthorName { get; }
[BlockField("AuthorTitle", DisplayName = "Author Title")]
public partial string? AuthorTitle { get; }
[BlockField("PhotoUrl", DisplayName = "Author Photo")]
public partial string? PhotoUrl { get; }
}ImageBlock
/// <summary>
/// A standalone image block with alt text and optional caption.
/// </summary>
[StructBlock("Image", Description = "Image with alt text and caption", Icon = "image")]
public partial class ImageBlock
{
[BlockField("Url", Required = true, DisplayName = "Image URL")]
public partial string Url { get; }
[BlockField("AltText", Required = true, DisplayName = "Alt Text", MaxLength = 200)]
public partial string AltText { get; }
[BlockField("Caption", MaxLength = 300)]
public partial string? Caption { get; }
[BlockField("Width")]
public partial int? Width { get; }
[BlockField("Height")]
public partial int? Height { get; }
}/// <summary>
/// A standalone image block with alt text and optional caption.
/// </summary>
[StructBlock("Image", Description = "Image with alt text and caption", Icon = "image")]
public partial class ImageBlock
{
[BlockField("Url", Required = true, DisplayName = "Image URL")]
public partial string Url { get; }
[BlockField("AltText", Required = true, DisplayName = "Alt Text", MaxLength = 200)]
public partial string AltText { get; }
[BlockField("Caption", MaxLength = 300)]
public partial string? Caption { get; }
[BlockField("Width")]
public partial int? Width { get; }
[BlockField("Height")]
public partial int? Height { get; }
}StreamField: Composable Block Sequences
The StreamField Attribute
/// <summary>
/// Declares a property as a composable sequence of typed content blocks.
/// Stored as a JSON array. Each element carries a type discriminator.
/// The developer specifies which block types are allowed in the stream.
/// </summary>
[MetaConcept("StreamField")]
[MetaReference("AllowedBlocks", "StructBlock", Multiplicity = "1..*")]
[MetaConstraint("MustHaveAllowedTypes",
"AllowedBlockTypes.Length > 0",
Message = "StreamField must specify at least one allowed block type")]
public sealed class StreamFieldAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("AllowedBlockTypes", "Type[]", Required = true)]
public Type[] AllowedBlockTypes { get; set; }
[MetaProperty("MinBlocks", "int")]
public int MinBlocks { get; set; } = 0;
[MetaProperty("MaxBlocks", "int")]
public int? MaxBlocks { get; set; }
}/// <summary>
/// Declares a property as a composable sequence of typed content blocks.
/// Stored as a JSON array. Each element carries a type discriminator.
/// The developer specifies which block types are allowed in the stream.
/// </summary>
[MetaConcept("StreamField")]
[MetaReference("AllowedBlocks", "StructBlock", Multiplicity = "1..*")]
[MetaConstraint("MustHaveAllowedTypes",
"AllowedBlockTypes.Length > 0",
Message = "StreamField must specify at least one allowed block type")]
public sealed class StreamFieldAttribute : Attribute
{
[MetaProperty("Name", "string", Required = true)]
public string Name { get; set; }
[MetaProperty("AllowedBlockTypes", "Type[]", Required = true)]
public Type[] AllowedBlockTypes { get; set; }
[MetaProperty("MinBlocks", "int")]
public int MinBlocks { get; set; } = 0;
[MetaProperty("MaxBlocks", "int")]
public int? MaxBlocks { get; set; }
}Usage on an Entity
[AggregateRoot("BlogPost", BoundedContext = "Content")]
[HasPart(typeof(RoutablePart))]
[HasPart(typeof(SeoablePart))]
[HasPart(typeof(TaggablePart))]
[HasPart(typeof(AuditablePart))]
[HasPart(typeof(VersionablePart))]
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; }
[StreamField("Body", AllowedBlockTypes = new[]
{
typeof(HeroBlock),
typeof(RichTextBlock),
typeof(TestimonialBlock),
typeof(ImageBlock)
})]
public partial IReadOnlyList<IContentBlock> Body { get; }
[Invariant("Blog post must have a title")]
private Result HasTitle()
=> !string.IsNullOrWhiteSpace(Title)
? Result.Success()
: Result.Failure("Blog post must have a title");
[Invariant("Blog post body must have at least one block")]
private Result HasBody()
=> Body.Count > 0
? Result.Success()
: Result.Failure("Blog post body must have at least one block");
}[AggregateRoot("BlogPost", BoundedContext = "Content")]
[HasPart(typeof(RoutablePart))]
[HasPart(typeof(SeoablePart))]
[HasPart(typeof(TaggablePart))]
[HasPart(typeof(AuditablePart))]
[HasPart(typeof(VersionablePart))]
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; }
[StreamField("Body", AllowedBlockTypes = new[]
{
typeof(HeroBlock),
typeof(RichTextBlock),
typeof(TestimonialBlock),
typeof(ImageBlock)
})]
public partial IReadOnlyList<IContentBlock> Body { get; }
[Invariant("Blog post must have a title")]
private Result HasTitle()
=> !string.IsNullOrWhiteSpace(Title)
? Result.Success()
: Result.Failure("Blog post must have a title");
[Invariant("Blog post body must have at least one block")]
private Result HasBody()
=> Body.Count > 0
? Result.Success()
: Result.Failure("Blog post body must have at least one block");
}StreamField JSON Structure
A StreamField serializes as a JSON array where each element has a type discriminator and a value object containing the block's fields:
[
{
"type": "Hero",
"value": {
"heading": "Welcome to Our Blog",
"subheading": "Thoughts on software architecture",
"backgroundImageUrl": "/images/hero-bg.jpg",
"ctaText": "Read More",
"ctaUrl": "#latest"
}
},
{
"type": "RichText",
"value": {
"htmlContent": "<p>In this post, we explore the design decisions...</p>"
}
},
{
"type": "Testimonial",
"value": {
"quote": "This framework changed how we think about CMS development.",
"authorName": "Jane Smith",
"authorTitle": "CTO, TechCorp",
"photoUrl": "/images/testimonials/jane.jpg"
}
},
{
"type": "Image",
"value": {
"url": "/images/architecture-diagram.png",
"altText": "CMF Architecture Diagram",
"caption": "The five-stage generation pipeline",
"width": 1200,
"height": 800
}
}
][
{
"type": "Hero",
"value": {
"heading": "Welcome to Our Blog",
"subheading": "Thoughts on software architecture",
"backgroundImageUrl": "/images/hero-bg.jpg",
"ctaText": "Read More",
"ctaUrl": "#latest"
}
},
{
"type": "RichText",
"value": {
"htmlContent": "<p>In this post, we explore the design decisions...</p>"
}
},
{
"type": "Testimonial",
"value": {
"quote": "This framework changed how we think about CMS development.",
"authorName": "Jane Smith",
"authorTitle": "CTO, TechCorp",
"photoUrl": "/images/testimonials/jane.jpg"
}
},
{
"type": "Image",
"value": {
"url": "/images/architecture-diagram.png",
"altText": "CMF Architecture Diagram",
"caption": "The five-stage generation pipeline",
"width": 1200,
"height": 800
}
}
]Versioning: Draft/Publish Workflow
The VersionablePart integrates with the entity lifecycle to support content versioning. When attached to an aggregate, the generated code maintains two states:
- Current (published): the live version visible to end users
- Draft: a working copy that can be edited without affecting the published version
How It Works
- When an entity with
[HasPart(typeof(VersionablePart))]is created, it starts asVersionStatus.DraftwithVersionNumber = 1. - Publishing creates a snapshot. The draft becomes the current published version.
- Editing a published entity creates a new draft (VersionNumber increments). The published version remains unchanged until the next publish.
- Archiving removes the entity from public access but preserves it in the database.
Generated Version Management
// Generated: BlogPostVersionManager.g.cs
public class BlogPostVersionManager
{
private readonly AppDbContext _db;
public BlogPostVersionManager(AppDbContext db) => _db = db;
/// <summary>
/// Returns the published version, or null if unpublished.
/// </summary>
public async Task<BlogPost?> GetPublishedAsync(
BlogPostId id, CancellationToken ct = default)
=> await _db.BlogPosts
.Where(p => p.Id == id
&& p.VersionablePart.Status == VersionStatus.Published)
.FirstOrDefaultAsync(ct);
/// <summary>
/// Returns the draft version (latest), or null if none exists.
/// </summary>
public async Task<BlogPost?> GetDraftAsync(
BlogPostId id, CancellationToken ct = default)
=> await _db.BlogPosts
.Where(p => p.Id == id
&& p.VersionablePart.Status == VersionStatus.Draft)
.FirstOrDefaultAsync(ct);
/// <summary>
/// Publishes the current draft. Sets status to Published,
/// archives the previous published version, and records
/// the publish timestamp.
/// </summary>
public async Task<Result> PublishAsync(
BlogPostId id, string publishedBy, CancellationToken ct = default)
{
// Archive current published version
// Promote draft to published
// Set PublishedAt, PublishedBy, increment VersionNumber
// ...
}
}// Generated: BlogPostVersionManager.g.cs
public class BlogPostVersionManager
{
private readonly AppDbContext _db;
public BlogPostVersionManager(AppDbContext db) => _db = db;
/// <summary>
/// Returns the published version, or null if unpublished.
/// </summary>
public async Task<BlogPost?> GetPublishedAsync(
BlogPostId id, CancellationToken ct = default)
=> await _db.BlogPosts
.Where(p => p.Id == id
&& p.VersionablePart.Status == VersionStatus.Published)
.FirstOrDefaultAsync(ct);
/// <summary>
/// Returns the draft version (latest), or null if none exists.
/// </summary>
public async Task<BlogPost?> GetDraftAsync(
BlogPostId id, CancellationToken ct = default)
=> await _db.BlogPosts
.Where(p => p.Id == id
&& p.VersionablePart.Status == VersionStatus.Draft)
.FirstOrDefaultAsync(ct);
/// <summary>
/// Publishes the current draft. Sets status to Published,
/// archives the previous published version, and records
/// the publish timestamp.
/// </summary>
public async Task<Result> PublishAsync(
BlogPostId id, string publishedBy, CancellationToken ct = default)
{
// Archive current published version
// Promote draft to published
// Set PublishedAt, PublishedBy, increment VersionNumber
// ...
}
}Integration with Workflow DSL
When an entity has both [HasPart(typeof(VersionablePart))] and [HasWorkflow("Editorial")] (see Part VIII), the workflow controls when publishing is allowed. The "Publish" transition in the workflow triggers BlogPostVersionManager.PublishAsync(). This means content must pass through editorial review before it becomes visible.
Generated Artifacts
The Content DSL source generator produces five categories of artifacts from the attributed partial classes:
1. JSON Serialization (System.Text.Json)
// Generated: ContentBlockJsonConverter.g.cs
public class ContentBlockJsonConverter : JsonConverter<IContentBlock>
{
public override IContentBlock? Read(
ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var type = doc.RootElement.GetProperty("type").GetString();
return type switch
{
"Hero" => JsonSerializer.Deserialize<HeroBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
"RichText" => JsonSerializer.Deserialize<RichTextBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
"Testimonial" => JsonSerializer.Deserialize<TestimonialBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
"Image" => JsonSerializer.Deserialize<ImageBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
_ => throw new JsonException($"Unknown block type: {type}")
};
}
public override void Write(
Utf8JsonWriter writer, IContentBlock value,
JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("type", value.BlockType);
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, value, value.GetType(), options);
writer.WriteEndObject();
}
}// Generated: ContentBlockJsonConverter.g.cs
public class ContentBlockJsonConverter : JsonConverter<IContentBlock>
{
public override IContentBlock? Read(
ref Utf8JsonReader reader, Type typeToConvert,
JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var type = doc.RootElement.GetProperty("type").GetString();
return type switch
{
"Hero" => JsonSerializer.Deserialize<HeroBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
"RichText" => JsonSerializer.Deserialize<RichTextBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
"Testimonial" => JsonSerializer.Deserialize<TestimonialBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
"Image" => JsonSerializer.Deserialize<ImageBlock>(
doc.RootElement.GetProperty("value").GetRawText(), options),
_ => throw new JsonException($"Unknown block type: {type}")
};
}
public override void Write(
Utf8JsonWriter writer, IContentBlock value,
JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("type", value.BlockType);
writer.WritePropertyName("value");
JsonSerializer.Serialize(writer, value, value.GetType(), options);
writer.WriteEndObject();
}
}2. Admin Field Editors
// Generated: BlogPostFormFields.g.cs (partial Blazor component)
// Part fields injected into the admin form for BlogPost:
// ── RoutablePart fields ──
<AdminTextField Label="Slug" @bind-Value="Model.RoutablePart.Slug"
Required="true" MaxLength="200"
HelpText="URL-friendly identifier, auto-generated from title" />
// ── SeoablePart fields ──
<AdminTextField Label="Meta Title" @bind-Value="Model.SeoablePart.MetaTitle"
MaxLength="70" HelpText="Browser tab title and search result heading" />
<AdminTextArea Label="Meta Description" @bind-Value="Model.SeoablePart.MetaDescription"
MaxLength="160" HelpText="Search result snippet text" />
// ── StreamField editor ──
<StreamFieldEditor Label="Body" @bind-Value="Model.Body"
AllowedBlockTypes="@(new[] { typeof(HeroBlock), typeof(RichTextBlock),
typeof(TestimonialBlock), typeof(ImageBlock) })" />// Generated: BlogPostFormFields.g.cs (partial Blazor component)
// Part fields injected into the admin form for BlogPost:
// ── RoutablePart fields ──
<AdminTextField Label="Slug" @bind-Value="Model.RoutablePart.Slug"
Required="true" MaxLength="200"
HelpText="URL-friendly identifier, auto-generated from title" />
// ── SeoablePart fields ──
<AdminTextField Label="Meta Title" @bind-Value="Model.SeoablePart.MetaTitle"
MaxLength="70" HelpText="Browser tab title and search result heading" />
<AdminTextArea Label="Meta Description" @bind-Value="Model.SeoablePart.MetaDescription"
MaxLength="160" HelpText="Search result snippet text" />
// ── StreamField editor ──
<StreamFieldEditor Label="Body" @bind-Value="Model.Body"
AllowedBlockTypes="@(new[] { typeof(HeroBlock), typeof(RichTextBlock),
typeof(TestimonialBlock), typeof(ImageBlock) })" />3. API Schema Types
// Generated: BlogPostApiDto.g.cs
public record BlogPostApiDto
{
public Guid Id { get; init; }
public string Title { get; init; } = "";
public string Author { get; init; } = "";
// Part fields flattened into DTO
public string Slug { get; init; } = "";
public string UrlPath { get; init; } = "";
public string? MetaTitle { get; init; }
public string? MetaDescription { get; init; }
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public DateTimeOffset CreatedAt { get; init; }
public string CreatedBy { get; init; } = "";
public int VersionNumber { get; init; }
public string Status { get; init; } = "";
// StreamField as typed block array
public IReadOnlyList<ContentBlockDto> Body { get; init; } = Array.Empty<ContentBlockDto>();
}// Generated: BlogPostApiDto.g.cs
public record BlogPostApiDto
{
public Guid Id { get; init; }
public string Title { get; init; } = "";
public string Author { get; init; } = "";
// Part fields flattened into DTO
public string Slug { get; init; } = "";
public string UrlPath { get; init; } = "";
public string? MetaTitle { get; init; }
public string? MetaDescription { get; init; }
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public DateTimeOffset CreatedAt { get; init; }
public string CreatedBy { get; init; } = "";
public int VersionNumber { get; init; }
public string Status { get; init; } = "";
// StreamField as typed block array
public IReadOnlyList<ContentBlockDto> Body { get; init; } = Array.Empty<ContentBlockDto>();
}4. Blazor Rendering Components
// Generated: HeroBlockRenderer.razor
@namespace MyStore.Client.Blocks
<section class="hero-block" style="background-image: url('@Block.BackgroundImageUrl')">
<div class="hero-content">
<h1>@Block.Heading</h1>
@if (!string.IsNullOrEmpty(Block.Subheading))
{
<p class="hero-subheading">@Block.Subheading</p>
}
@if (!string.IsNullOrEmpty(Block.CtaText))
{
<a href="@Block.CtaUrl" class="hero-cta">@Block.CtaText</a>
}
</div>
</section>
@code {
[Parameter, EditorRequired]
public HeroBlock Block { get; set; } = default!;
}// Generated: HeroBlockRenderer.razor
@namespace MyStore.Client.Blocks
<section class="hero-block" style="background-image: url('@Block.BackgroundImageUrl')">
<div class="hero-content">
<h1>@Block.Heading</h1>
@if (!string.IsNullOrEmpty(Block.Subheading))
{
<p class="hero-subheading">@Block.Subheading</p>
}
@if (!string.IsNullOrEmpty(Block.CtaText))
{
<a href="@Block.CtaUrl" class="hero-cta">@Block.CtaText</a>
}
</div>
</section>
@code {
[Parameter, EditorRequired]
public HeroBlock Block { get; set; } = default!;
}// Generated: StreamFieldRenderer.razor
@namespace MyStore.Client.Components
@foreach (var block in Blocks)
{
@switch (block)
{
case HeroBlock hero:
<HeroBlockRenderer Block="hero" />
break;
case RichTextBlock richText:
<RichTextBlockRenderer Block="richText" />
break;
case TestimonialBlock testimonial:
<TestimonialBlockRenderer Block="testimonial" />
break;
case ImageBlock image:
<ImageBlockRenderer Block="image" />
break;
}
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<IContentBlock> Blocks { get; set; } = Array.Empty<IContentBlock>();
}// Generated: StreamFieldRenderer.razor
@namespace MyStore.Client.Components
@foreach (var block in Blocks)
{
@switch (block)
{
case HeroBlock hero:
<HeroBlockRenderer Block="hero" />
break;
case RichTextBlock richText:
<RichTextBlockRenderer Block="richText" />
break;
case TestimonialBlock testimonial:
<TestimonialBlockRenderer Block="testimonial" />
break;
case ImageBlock image:
<ImageBlockRenderer Block="image" />
break;
}
}
@code {
[Parameter, EditorRequired]
public IReadOnlyList<IContentBlock> Blocks { get; set; } = Array.Empty<IContentBlock>();
}5. EF Core Persistence
// Generated: BlogPostConfiguration.g.cs (Content DSL additions)
public partial class BlogPostEntityTypeConfiguration
{
partial void ConfigureContentParts(EntityTypeBuilder<BlogPost> builder)
{
// RoutablePart: owned type with indexed slug
builder.OwnsOne(x => x.RoutablePart, rp =>
{
rp.Property(p => p.Slug).HasMaxLength(200).IsRequired();
rp.HasIndex(p => p.Slug).IsUnique();
rp.Property(p => p.UrlPath).HasMaxLength(500);
});
// SeoablePart: owned type
builder.OwnsOne(x => x.SeoablePart, sp =>
{
sp.Property(p => p.MetaTitle).HasMaxLength(70);
sp.Property(p => p.MetaDescription).HasMaxLength(160);
});
// TaggablePart: JSON column
builder.OwnsOne(x => x.TaggablePart, tp =>
{
tp.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonDefaults.Options),
v => JsonSerializer.Deserialize<List<string>>(v, JsonDefaults.Options)!)
.HasColumnType("jsonb");
});
// VersionablePart: owned type with status index
builder.OwnsOne(x => x.VersionablePart, vp =>
{
vp.Property(p => p.Status).HasConversion<string>();
vp.HasIndex(p => p.Status);
});
// StreamField: JSON column
builder.Property(x => x.Body)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonDefaults.Options),
v => JsonSerializer.Deserialize<List<IContentBlock>>(v, JsonDefaults.Options)!)
.HasColumnType("jsonb");
}
}// Generated: BlogPostConfiguration.g.cs (Content DSL additions)
public partial class BlogPostEntityTypeConfiguration
{
partial void ConfigureContentParts(EntityTypeBuilder<BlogPost> builder)
{
// RoutablePart: owned type with indexed slug
builder.OwnsOne(x => x.RoutablePart, rp =>
{
rp.Property(p => p.Slug).HasMaxLength(200).IsRequired();
rp.HasIndex(p => p.Slug).IsUnique();
rp.Property(p => p.UrlPath).HasMaxLength(500);
});
// SeoablePart: owned type
builder.OwnsOne(x => x.SeoablePart, sp =>
{
sp.Property(p => p.MetaTitle).HasMaxLength(70);
sp.Property(p => p.MetaDescription).HasMaxLength(160);
});
// TaggablePart: JSON column
builder.OwnsOne(x => x.TaggablePart, tp =>
{
tp.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonDefaults.Options),
v => JsonSerializer.Deserialize<List<string>>(v, JsonDefaults.Options)!)
.HasColumnType("jsonb");
});
// VersionablePart: owned type with status index
builder.OwnsOne(x => x.VersionablePart, vp =>
{
vp.Property(p => p.Status).HasConversion<string>();
vp.HasIndex(p => p.Status);
});
// StreamField: JSON column
builder.Property(x => x.Body)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonDefaults.Options),
v => JsonSerializer.Deserialize<List<IContentBlock>>(v, JsonDefaults.Options)!)
.HasColumnType("jsonb");
}
}Compile-Time Validation (Stage 1)
| Diagnostic | Severity | Rule |
|---|---|---|
| CNT001 | Error | [ContentPart] has zero [PartField] properties |
| CNT002 | Error | [HasPart] target type is not annotated with [ContentPart] |
| CNT003 | Error | [StructBlock] has zero [BlockField] properties |
| CNT004 | Error | [StreamField] specifies zero allowed block types |
| CNT005 | Error | [StreamField] references a type not annotated with [StructBlock] |
| CNT006 | Warning | [ListBlock] has MaxItems less than MinItems |
| CNT100 | Warning | Entity has [HasPart(typeof(VersionablePart))] but no [HasWorkflow] |
Comparison: CMF vs Existing Systems
Content Parts
| Aspect | Orchard Core | CMF |
|---|---|---|
| Declaration | ContentPartDefinition + migrations |
[ContentPart] attribute |
| Attachment | ContentTypeDefinition.WithPart() |
[HasPart(typeof(...))] on entity |
| Storage | Separate table per part | EF Core owned type (embedded columns) or JSON |
| Type safety | String-based part names | typeof() -- compiler-checked |
| Admin forms | Drivers + shapes (runtime) | Source-generated Blazor fields (compile-time) |
| API exposure | Shape-based JSON | Typed DTO with flattened part fields |
StreamField
| Aspect | Wagtail (Python/Django) | CMF |
|---|---|---|
| Declaration | StreamField([(...), ...]) |
[StreamField(AllowedBlockTypes = ...)] |
| Block definition | Python class with StructBlock |
C# class with [StructBlock] |
| Storage | JSON in database column | JSON in database column (jsonb) |
| Serialization | Django REST framework | System.Text.Json with type-discriminated converter |
| Rendering | Django templates | Blazor components (WASM) |
| Type safety | Runtime validation | Compile-time: allowed types checked by analyzer |
| Admin editor | Wagtail StreamField widget (JS) | Generated StreamFieldEditor (Blazor) |
Complete Example: BlogPost Aggregate
What the Developer Writes (~50 lines)
namespace MyStore.Lib.Content;
// ── Aggregate with five content parts and a StreamField body ──
[AggregateRoot("BlogPost", BoundedContext = "Content")]
[HasPart(typeof(RoutablePart))]
[HasPart(typeof(SeoablePart))]
[HasPart(typeof(TaggablePart))]
[HasPart(typeof(AuditablePart))]
[HasPart(typeof(VersionablePart))]
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; }
[Invariant("Blog post must have a title")]
private Result HasTitle()
=> !string.IsNullOrWhiteSpace(Title)
? Result.Success()
: Result.Failure("Blog post must have a title");
[Invariant("Blog post body must have at least one block")]
private Result HasBody()
=> Body.Count > 0
? Result.Success()
: Result.Failure("Blog post body must have at least one block");
}namespace MyStore.Lib.Content;
// ── Aggregate with five content parts and a StreamField body ──
[AggregateRoot("BlogPost", BoundedContext = "Content")]
[HasPart(typeof(RoutablePart))]
[HasPart(typeof(SeoablePart))]
[HasPart(typeof(TaggablePart))]
[HasPart(typeof(AuditablePart))]
[HasPart(typeof(VersionablePart))]
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; }
[Invariant("Blog post must have a title")]
private Result HasTitle()
=> !string.IsNullOrWhiteSpace(Title)
? Result.Success()
: Result.Failure("Blog post must have a title");
[Invariant("Blog post body must have at least one block")]
private Result HasBody()
=> Body.Count > 0
? Result.Success()
: Result.Failure("Blog post body must have at least one block");
}What the Compiler Generates (~1,500 lines)
| Generated Artifact | Approx. Lines | Stage |
|---|---|---|
| Entity implementation (backing fields, part properties) | 200 | 2 |
| Fluent builder with part initialization | 150 | 2 |
EnsureInvariants() |
15 | 2 |
| EF Core configuration (entity + parts + StreamField) | 120 | 2-3 |
| Repository interface + implementation | 100 | 2-3 |
ContentBlockJsonConverter (type-discriminated) |
80 | 2 |
BlogPostVersionManager |
100 | 3 |
| API DTO with flattened part fields | 50 | 3 |
| REST controller endpoints | 150 | 3 |
| Admin form with part fields + StreamField editor | 200 | 3 |
| Blazor block renderers (4 blocks) | 200 | 3 |
StreamFieldRenderer component |
40 | 3 |
| Total | ~1,400 |
The developer writes ~50 lines of attributed partial classes. The Content DSL and DDD DSL generators together produce ~1,400 lines of production code covering persistence, API, admin UI, and client-side rendering.
Summary
The Content DSL extends the DDD DSL with two composition strategies:
Parts add capabilities horizontally. An entity gains SEO, routing, tagging, auditing, and versioning by listing
[HasPart]attributes. Parts are persisted as EF Core owned types. Their fields appear automatically in admin forms and API DTOs.Blocks structure content vertically. A
[StreamField]property holds an ordered, type-discriminated sequence of blocks stored as JSON. Each block type gets a generated JSON converter, admin editor, and Blazor rendering component.
Both strategies are declared with M3-annotated attributes (see Part III), validated at Stage 1, and processed at Stages 2-3. The developer defines the content model. The compiler generates the full stack from persistence to rendering.