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

Part 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 BlogPost aggregate 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 HeroBlock contains 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

Diagram

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

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

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

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

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

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

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

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 }

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

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

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

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

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

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

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

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

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

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

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
    }
  }
]
Diagram

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

  1. When an entity with [HasPart(typeof(VersionablePart))] is created, it starts as VersionStatus.Draft with VersionNumber = 1.
  2. Publishing creates a snapshot. The draft becomes the current published version.
  3. Editing a published entity creates a new draft (VersionNumber increments). The published version remains unchanged until the next publish.
  4. 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
        // ...
    }
}

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

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) })" />

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

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: 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");
    }
}

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

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.