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