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 VI: Admin DSL -- Auto-Generated UI

Overview

The Admin DSL generates complete Blazor Server admin interfaces from a single [AdminModule] attribute. Where the DDD DSL (Part IV) generates the domain and persistence layers, and the Content DSL (Part V) generates content composition, the Admin DSL generates the management interface -- list views, forms, detail pages, filters, and batch actions.

The generator reads the aggregate's properties, content parts, relationships, and workflow state to produce Blazor components that are type-safe, paginated, and fully functional. The developer writes one attribute per admin module. The compiler writes the UI.


Core Attributes

AdminModule

namespace Cmf.Admin.Lib;

/// <summary>
/// Declares an admin module for a given aggregate root. The source
/// generator produces a complete Blazor CRUD interface: list page,
/// form page, detail page, and navigation entry.
/// </summary>
[MetaConcept("AdminModule")]
[MetaReference("Aggregate", "AggregateRoot", Multiplicity = "1")]
[MetaConstraint("AggregateMustExist",
    "ReferencedType.IsAnnotatedWith('AggregateRoot')",
    Message = "AdminModule must reference an [AggregateRoot] type")]
public sealed class AdminModuleAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("Aggregate", "Type", Required = true)]
    public Type Aggregate { get; }

    [MetaProperty("Icon", "string")]
    public string? Icon { get; set; }

    [MetaProperty("Group", "string")]
    public string? Group { get; set; }

    [MetaProperty("PageSize", "int")]
    public int PageSize { get; set; } = 25;

    public AdminModuleAttribute(string name, Type aggregate)
    {
        Name = name;
        Aggregate = aggregate;
    }
}

AdminField

/// <summary>
/// Overrides the default rendering for a specific field in the admin
/// form and list view. Without this attribute, the generator infers
/// display types from property types (string → text input, bool → checkbox,
/// decimal → number input, enum → dropdown).
/// </summary>
[MetaConcept("AdminField")]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class AdminFieldAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("DisplayType", "string")]
    public string? DisplayType { get; set; }

    [MetaProperty("DisplayName", "string")]
    public string? DisplayName { get; set; }

    [MetaProperty("ReadOnly", "bool")]
    public bool ReadOnly { get; set; } = false;

    [MetaProperty("HideInList", "bool")]
    public bool HideInList { get; set; } = false;

    [MetaProperty("HideInForm", "bool")]
    public bool HideInForm { get; set; } = false;

    [MetaProperty("Order", "int")]
    public int Order { get; set; } = 0;

    public AdminFieldAttribute(string name) => Name = name;
}

Built-in display types:

DisplayType Renders As Suitable For
"Text" Single-line text input Strings
"TextArea" Multi-line text area Long text
"RichText" WYSIWYG editor HTML content
"Number" Number input int, decimal
"Currency" Number with currency symbol Money amounts
"Checkbox" Toggle checkbox bool
"Dropdown" Select dropdown Enums
"DatePicker" Date picker DateTime, DateTimeOffset
"ImageUpload" Image upload with preview Image URLs
"Slug" Slug editor with auto-generate URL slugs
"Tags" Tag input with autocomplete Tag collections
"StreamField" Block editor StreamField properties

AdminFilter

/// <summary>
/// Adds a filter control to the list view for the admin module.
/// Filters appear above the list table and apply server-side
/// query filtering via the generated repository.
/// </summary>
[MetaConcept("AdminFilter")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class AdminFilterAttribute : Attribute
{
    [MetaProperty("FieldName", "string", Required = true)]
    public string FieldName { get; }

    [MetaProperty("FilterType", "string")]
    public string FilterType { get; set; } = "Text";

    [MetaProperty("DisplayName", "string")]
    public string? DisplayName { get; set; }

    [MetaProperty("Options", "string[]")]
    public string[]? Options { get; set; }

    public AdminFilterAttribute(string fieldName) => FieldName = fieldName;
}

Built-in filter types:

FilterType UI Control Query Operator
"Text" Text input LIKE '%value%'
"ExactMatch" Text input = value
"Dropdown" Select dropdown = value
"MultiSelect" Multi-select checkboxes IN (values)
"DateRange" From/to date pickers BETWEEN from AND to
"NumberRange" From/to number inputs BETWEEN from AND to
"Boolean" Yes/No/All toggle = true / = false / no filter

AdminAction

/// <summary>
/// Declares a custom bulk action available on the list view.
/// When the user selects rows and invokes the action, the framework
/// dispatches the specified command for each selected entity.
/// </summary>
[MetaConcept("AdminAction")]
[MetaReference("Command", "Command", Multiplicity = "0..1")]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class AdminActionAttribute : Attribute
{
    [MetaProperty("Name", "string", Required = true)]
    public string Name { get; }

    [MetaProperty("Command", "string", Required = true)]
    public string Command { get; set; }

    [MetaProperty("Icon", "string")]
    public string? Icon { get; set; }

    [MetaProperty("ConfirmationMessage", "string")]
    public string? ConfirmationMessage { get; set; }

    [MetaProperty("RequiresRole", "string")]
    public string? RequiresRole { get; set; }

    public AdminActionAttribute(string name) => Name = name;
}

Admin Generation Pipeline

Diagram

Complete Example: Product Admin Module

What the Developer Writes

namespace MyStore.Lib.Catalog;

[AggregateRoot("Product", BoundedContext = "Catalog")]
[HasPart(typeof(RoutablePart))]
[HasPart(typeof(SeoablePart))]
[HasPart(typeof(VersionablePart))]
public partial class Product
{
    [EntityId]
    public partial ProductId Id { get; }

    [Property("Name", Required = true, MaxLength = 200)]
    [AdminField("Name", Order = 1)]
    public partial string Name { get; }

    [Property("Sku", Required = true, MaxLength = 50)]
    [AdminField("SKU", Order = 2)]
    public partial string Sku { get; }

    [Property("Description", MaxLength = 2000)]
    [AdminField("Description", DisplayType = "TextArea", HideInList = true, Order = 3)]
    public partial string? Description { get; }

    [Property("Price", Required = true)]
    [AdminField("Price", DisplayType = "Currency", Order = 4)]
    public partial decimal Price { get; }

    [Property("Category", Required = true)]
    [AdminField("Category", DisplayType = "Dropdown", Order = 5)]
    public partial ProductCategory Category { get; }

    [Property("IsActive")]
    [AdminField("Active", Order = 6)]
    public partial bool IsActive { get; }

    [Property("StockQuantity", Required = true)]
    [AdminField("Stock", Order = 7)]
    public partial int StockQuantity { get; }

    [StreamField("Gallery", AllowedBlockTypes = new[] { typeof(ImageBlock) })]
    [AdminField("Gallery", DisplayType = "StreamField", HideInList = true, Order = 8)]
    public partial IReadOnlyList<IContentBlock> Gallery { get; }

    [Invariant("Product must have a positive price")]
    private Result PriceIsPositive()
        => Price > 0
            ? Result.Success()
            : Result.Failure("Product price must be positive");

    [Invariant("Product must have non-negative stock")]
    private Result StockIsNonNegative()
        => StockQuantity >= 0
            ? Result.Success()
            : Result.Failure("Stock quantity cannot be negative");
}

public enum ProductCategory { Electronics, Clothing, Books, Home, Sports }

The Admin Module Declaration

namespace MyStore.Admin;

/// <summary>
/// Declares the Product admin module. The generator produces
/// list, form, and detail pages for the Product aggregate.
/// </summary>
[AdminModule("Products", typeof(Product), Icon = "box", Group = "Catalog")]
[AdminFilter("Category", FilterType = "Dropdown", DisplayName = "Category")]
[AdminFilter("IsActive", FilterType = "Boolean", DisplayName = "Active Status")]
[AdminFilter("Price", FilterType = "NumberRange", DisplayName = "Price Range")]
[AdminAction("Publish", Command = "PublishProduct",
    Icon = "send", ConfirmationMessage = "Publish selected products?",
    RequiresRole = "Editor")]
[AdminAction("Deactivate", Command = "DeactivateProduct",
    Icon = "x-circle", ConfirmationMessage = "Deactivate selected products?",
    RequiresRole = "Admin")]
public partial class ProductAdminModule { }

What the Compiler Generates

1. ProductListPage.razor

// Generated: ProductListPage.razor
@page "/admin/products"
@namespace MyStore.Admin.Pages
@attribute [Authorize(Roles = "Editor,Admin")]
@inject IProductRepository Repository
@inject NavigationManager Navigation

<PageTitle>Products</PageTitle>

<div class="admin-list-page">
    <header class="admin-header">
        <h1>Products</h1>
        <button class="btn btn-primary" @onclick="NavigateToCreate">
            + New Product
        </button>
    </header>

    @* ── Filters ── *@
    <div class="admin-filters">
        <AdminDropdownFilter Label="Category" @bind-Value="Filters.Category"
            Options="@ProductCategoryOptions" />
        <AdminBooleanFilter Label="Active Status" @bind-Value="Filters.IsActive" />
        <AdminNumberRangeFilter Label="Price Range"
            @bind-Min="Filters.PriceMin" @bind-Max="Filters.PriceMax" />
        <button class="btn btn-secondary" @onclick="ApplyFilters">Apply</button>
        <button class="btn btn-link" @onclick="ClearFilters">Clear</button>
    </div>

    @* ── Batch Actions ── *@
    @if (SelectedIds.Count > 0)
    {
        <div class="admin-batch-actions">
            <span>@SelectedIds.Count selected</span>
            <AdminBatchAction Label="Publish" Icon="send"
                OnClick="BatchPublish"
                Confirmation="Publish selected products?" />
            <AdminBatchAction Label="Deactivate" Icon="x-circle"
                OnClick="BatchDeactivate"
                Confirmation="Deactivate selected products?" />
        </div>
    }

    @* ── Data Table ── *@
    <AdminDataTable TItem="Product" Items="Products" @bind-SelectedIds="SelectedIds"
        OnSort="HandleSort" CurrentSort="SortColumn" SortDescending="SortDesc">
        <AdminColumn Title="Name" Field="Name" Sortable="true" />
        <AdminColumn Title="SKU" Field="Sku" Sortable="true" />
        <AdminColumn Title="Price" Field="Price" Sortable="true"
            Format="Currency" />
        <AdminColumn Title="Category" Field="Category" Sortable="true" />
        <AdminColumn Title="Active" Field="IsActive" Sortable="true"
            DisplayType="Badge" />
        <AdminColumn Title="Stock" Field="StockQuantity" Sortable="true" />
        <AdminActionsColumn>
            <button @onclick="() => NavigateToEdit(context.Id)">Edit</button>
            <button @onclick="() => NavigateToDetail(context.Id)">View</button>
        </AdminActionsColumn>
    </AdminDataTable>

    @* ── Pagination ── *@
    <AdminPagination TotalItems="TotalCount" PageSize="25"
        @bind-CurrentPage="CurrentPage" OnPageChanged="LoadPage" />
</div>

@code {
    private List<Product> Products { get; set; } = new();
    private HashSet<ProductId> SelectedIds { get; set; } = new();
    private ProductListFilters Filters { get; set; } = new();
    private int TotalCount { get; set; }
    private int CurrentPage { get; set; } = 1;
    private string SortColumn { get; set; } = "Name";
    private bool SortDesc { get; set; } = false;

    protected override async Task OnInitializedAsync()
        => await LoadPage(1);

    private async Task LoadPage(int page)
    {
        var result = await Repository.GetPagedAsync(
            page, pageSize: 25, Filters, SortColumn, SortDesc);
        Products = result.Items.ToList();
        TotalCount = result.TotalCount;
        CurrentPage = page;
    }

    // Navigation, sorting, batch action handlers...
}

2. ProductFormPage.razor

// Generated: ProductFormPage.razor
@page "/admin/products/new"
@page "/admin/products/{Id:guid}/edit"
@namespace MyStore.Admin.Pages
@attribute [Authorize(Roles = "Editor,Admin")]
@inject IProductRepository Repository
@inject ICommandDispatcher Commands
@inject NavigationManager Navigation

<PageTitle>@(IsEdit ? "Edit Product" : "New Product")</PageTitle>

<div class="admin-form-page">
    <header class="admin-header">
        <h1>@(IsEdit ? $"Edit: {Model.Name}" : "New Product")</h1>
    </header>

    <EditForm Model="Model" OnValidSubmit="HandleSubmit">
        <DataAnnotationsValidator />
        <ValidationSummary />

        @* ── Core Fields ── *@
        <fieldset>
            <legend>Product Information</legend>
            <AdminTextField Label="Name" @bind-Value="Model.Name"
                Required="true" MaxLength="200" />
            <AdminTextField Label="SKU" @bind-Value="Model.Sku"
                Required="true" MaxLength="50" />
            <AdminTextArea Label="Description" @bind-Value="Model.Description"
                MaxLength="2000" />
            <AdminCurrencyField Label="Price" @bind-Value="Model.Price" />
            <AdminDropdownField Label="Category" @bind-Value="Model.Category"
                Options="@ProductCategoryOptions" />
            <AdminCheckboxField Label="Active" @bind-Value="Model.IsActive" />
            <AdminNumberField Label="Stock" @bind-Value="Model.StockQuantity" />
        </fieldset>

        @* ── Content Part Fields (auto-injected from [HasPart]) ── *@
        <fieldset>
            <legend>URL & Routing</legend>
            <AdminSlugField Label="Slug" @bind-Value="Model.RoutablePart.Slug"
                SourceField="Model.Name" />
        </fieldset>

        <fieldset>
            <legend>SEO</legend>
            <AdminTextField Label="Meta Title" @bind-Value="Model.SeoablePart.MetaTitle"
                MaxLength="70" HelpText="Browser tab title" />
            <AdminTextArea Label="Meta Description"
                @bind-Value="Model.SeoablePart.MetaDescription"
                MaxLength="160" HelpText="Search result snippet" />
            <AdminTextField Label="OG Image" @bind-Value="Model.SeoablePart.OgImage"
                HelpText="Social sharing image URL" />
        </fieldset>

        <fieldset>
            <legend>Publishing</legend>
            <AdminReadOnlyField Label="Version"
                Value="@Model.VersionablePart.VersionNumber.ToString()" />
            <AdminReadOnlyField Label="Status"
                Value="@Model.VersionablePart.Status.ToString()" />
        </fieldset>

        @* ── StreamField: Gallery ── *@
        <fieldset>
            <legend>Gallery</legend>
            <StreamFieldEditor @bind-Value="Model.Gallery"
                AllowedBlockTypes="@(new[] { typeof(ImageBlock) })" />
        </fieldset>

        <div class="admin-form-actions">
            <button type="submit" class="btn btn-primary">
                @(IsEdit ? "Save Changes" : "Create Product")
            </button>
            <button type="button" class="btn btn-secondary"
                @onclick="Cancel">Cancel</button>
        </div>
    </EditForm>
</div>

@code {
    [Parameter] public Guid? Id { get; set; }
    private ProductFormModel Model { get; set; } = new();
    private bool IsEdit => Id.HasValue;

    protected override async Task OnParametersSetAsync()
    {
        if (IsEdit)
        {
            var product = await Repository.GetByIdAsync(
                new ProductId(Id!.Value));
            if (product is not null)
                Model = ProductFormModel.FromEntity(product);
        }
    }

    private async Task HandleSubmit()
    {
        if (IsEdit)
            await Commands.DispatchAsync(Model.ToUpdateCommand());
        else
            await Commands.DispatchAsync(Model.ToCreateCommand());

        Navigation.NavigateTo("/admin/products");
    }
}

3. ProductDetailPage.razor

// Generated: ProductDetailPage.razor
@page "/admin/products/{Id:guid}"
@namespace MyStore.Admin.Pages
@attribute [Authorize(Roles = "Editor,Admin")]
@inject IProductRepository Repository

<PageTitle>Product: @Product?.Name</PageTitle>

<div class="admin-detail-page">
    @if (Product is not null)
    {
        <header class="admin-header">
            <h1>@Product.Name</h1>
            <div class="admin-header-actions">
                <a href="/admin/products/@Id/edit" class="btn btn-primary">Edit</a>
            </div>
        </header>

        <div class="admin-detail-grid">
            <AdminDetailField Label="SKU" Value="@Product.Sku" />
            <AdminDetailField Label="Price" Value="@Product.Price.ToString("C")" />
            <AdminDetailField Label="Category" Value="@Product.Category.ToString()" />
            <AdminDetailField Label="Active" Value="@Product.IsActive.ToString()" />
            <AdminDetailField Label="Stock" Value="@Product.StockQuantity.ToString()" />
            <AdminDetailField Label="Slug" Value="@Product.RoutablePart.Slug" />
            <AdminDetailField Label="URL" Value="@Product.RoutablePart.UrlPath" />
            <AdminDetailField Label="Version"
                Value="@Product.VersionablePart.VersionNumber.ToString()" />
            <AdminDetailField Label="Status"
                Value="@Product.VersionablePart.Status.ToString()" />
        </div>

        @if (!string.IsNullOrEmpty(Product.Description))
        {
            <section class="admin-detail-section">
                <h2>Description</h2>
                <p>@Product.Description</p>
            </section>
        }
    }
</div>

@code {
    [Parameter] public Guid Id { get; set; }
    private Product? Product { get; set; }

    protected override async Task OnParametersSetAsync()
        => Product = await Repository.GetByIdAsync(new ProductId(Id));
}

Nested Entity Editing

When an aggregate contains composed entities (via [Composition] from the DDD DSL, Part IV), the admin form generator produces inline editors for the child entities.

// Order aggregate with OrderLine composition (from Part IV)
[AggregateRoot("Order", BoundedContext = "Ordering")]
public partial class Order
{
    [Composition]
    public partial IReadOnlyList<OrderLine> Lines { get; }
}

[AdminModule("Orders", typeof(Order), Icon = "shopping-cart", Group = "Sales")]
public partial class OrderAdminModule { }

The generated OrderFormPage.razor includes an inline table editor for order lines:

// Generated: OrderFormPage.razor (excerpt)
<fieldset>
    <legend>Order Lines</legend>
    <AdminInlineTable TItem="OrderLineFormModel" @bind-Items="Model.Lines">
        <AdminInlineColumn Title="Product" Field="ProductName"
            DisplayType="Text" Required="true" />
        <AdminInlineColumn Title="Quantity" Field="Quantity"
            DisplayType="Number" Required="true" />
        <AdminInlineColumn Title="Unit Price" Field="UnitPrice"
            DisplayType="Currency" ReadOnly="true" />
        <AdminInlineColumn Title="Line Total" Field="LineTotal"
            DisplayType="Currency" ReadOnly="true" />
        <AdminInlineActions>
            <button @onclick="() => RemoveLine(context)">Remove</button>
        </AdminInlineActions>
    </AdminInlineTable>
    <button type="button" class="btn btn-secondary"
        @onclick="AddLine">+ Add Line</button>
</fieldset>

Content Part Fields in Admin Forms

When an aggregate has [HasPart] attributes (from the Content DSL, Part V), the admin form generator automatically includes fieldsets for each attached part. This is visible in the ProductFormPage example above: the RoutablePart, SeoablePart, and VersionablePart each get their own fieldset without the developer writing any form code.

The generator reads the aggregate's [HasPart] declarations, retrieves the part's [PartField] metadata, and emits the corresponding Blazor form fields. The mapping from [PartField] to admin control uses the same display type inference as [AdminField]:

PartField Type Default Admin Control
string Text input
string (MaxLength > 200) Text area
bool Checkbox
int, decimal Number input
DateTimeOffset Date picker
IReadOnlyList<string> Tag input
VersionStatus (enum) Dropdown

Requirement Compliance Dashboard

When the Requirements DSL (Part IX) is active in the project, the Admin DSL generator adds a compliance dashboard panel to each admin module. This panel shows:

  • Feature coverage: which features linked to this aggregate have specifications and tests
  • Acceptance criteria status: pass/fail/untested per criterion
  • Quality gate results: coverage percentage, pass rate
// Generated: ProductComplianceDashboard.razor (excerpt)
<div class="admin-compliance-dashboard">
    <h3>Requirement Compliance</h3>

    <AdminComplianceTable>
        <AdminComplianceRow Feature="AggregateDefinitionFeature"
            Status="FullyCovered" TestsPassed="4" TestsTotal="4"
            Coverage="92.3%" />
        <AdminComplianceRow Feature="ContentPartsFeature"
            Status="PartiallyCovered" TestsPassed="3" TestsTotal="5"
            Coverage="78.1%" />
        <AdminComplianceRow Feature="ListGenerationFeature"
            Status="FullyCovered" TestsPassed="4" TestsTotal="4"
            Coverage="95.0%" />
    </AdminComplianceTable>

    <AdminComplianceSummary TotalFeatures="3"
        CoveredFeatures="2" PartialFeatures="1" UncoveredFeatures="0"
        OverallCoverage="88.5%" />
</div>

This panel is only generated when the project references Cmf.Requirements.Lib. It draws data from the traceability matrix produced at Stage 4 (see Part IX).


Compile-Time Validation (Stage 1)

Diagnostic Severity Rule
ADM001 Error [AdminModule] references a type not annotated with [AggregateRoot]
ADM002 Error [AdminFilter] references a field name that does not exist on the aggregate
ADM003 Error [AdminAction] references a command that does not exist
ADM004 Warning [AdminField] DisplayType is unknown (not in the built-in set)
ADM005 Warning Aggregate has no [AdminModule] (no admin UI will be generated)
ADM006 Error [AdminAction] RequiresRole is empty string (use null for no restriction)

Summary

The Admin DSL transforms a single [AdminModule] attribute into a complete Blazor Server admin interface:

Generated Artifact Approx. Lines Stage
List page with sorting, pagination, filters 200 3
Form page with validation and part fields 250 3
Detail page (read-only) 100 3
Inline table editor per composed entity 80 each 3
Batch action handlers 40 each 3
Navigation sidebar entry 15 3
Compliance dashboard (if Requirements DSL active) 60 4
Total per admin module ~700-900

The developer writes ~30 lines of attribute declarations. The compiler generates ~700-900 lines of Blazor components. Content part fields, nested entity editors, and requirement compliance dashboards are included automatically based on the aggregate's DSL annotations from Parts IV, V, and IX.