What Gets Generated (Stages 2+3)
From a single aggregate decorated with DDD, Content, Admin, and Page attributes, the source generator pipeline produces the entire application stack:
The DDD generator produces entity implementations with backing fields, strongly-typed IDs, and domain event support. The EF Core generator derives IEntityTypeConfiguration<T> from [Composition] semantics. The Admin generator produces Blazor components for list, form, and detail views. The Pages generator produces widget Blazor components and REST API controllers.
The generated CQRS handler wires together the aggregate, repository, and event bus — using the Builder pattern for entity construction and Result for typed error handling:
// <auto-generated/>
public sealed class CreateProductCommandHandler
: ICommandHandler<CreateProductCommand, Result<Product>>
{
private readonly IProductRepository _repository;
public async Task<Result<Product>> HandleAsync(
CreateProductCommand command, CancellationToken ct)
{
var product = new Product.Builder()
.WithName(command.Name)
.WithPrice(command.Price)
.WithSlug(command.Slug)
.Build();
await _repository.SaveAsync(product, ct);
return Result<Product>.Success(product);
}
}// <auto-generated/>
public sealed class CreateProductCommandHandler
: ICommandHandler<CreateProductCommand, Result<Product>>
{
private readonly IProductRepository _repository;
public async Task<Result<Product>> HandleAsync(
CreateProductCommand command, CancellationToken ct)
{
var product = new Product.Builder()
.WithName(command.Name)
.WithPrice(command.Price)
.WithSlug(command.Slug)
.Build();
await _repository.SaveAsync(product, ct);
return Result<Product>.Success(product);
}
}Cross-cutting generators also produce: sitemap.xml from the page tree, search index configuration for [HasPart("Searchable")] aggregates, and the workflow engine for [HasWorkflow] content types.
The Five Stages in Practice
Each generator participates in a five-stage pipeline. A given attribute may flow through several stages, accumulating context as it goes:
| Stage | Name | Input | Output | Owner |
|---|---|---|---|---|
| 0 | Discovery | All partial class declarations carrying CMF attributes |
A CmfModel graph: aggregates, parts, blocks, widgets, workflows, requirements |
Roslyn IIncrementalGenerator collectors |
| 1 | Validation | The Stage 0 model | Diagnostics (CMF1xx..CMF4xx); fast-fail before any code is emitted | Compile-time analyzers |
| 2 | Domain & persistence | Validated model | Entity .g.cs, builders, value objects, EF configurations, repositories, command/query handlers, domain events |
DDD + EF generators |
| 3 | UI & API | Stage 2 outputs + admin/page/widget attributes | Blazor admin components, page widgets, REST controllers, GraphQL schema fragments, OpenAPI metadata | Admin + Pages generators |
| 4 | Cross-cutting | The complete Stage 2+3 output | Search index config, sitemap, workflow state machines, requirement coverage maps, audit log schemas | Aggregators that read the whole compilation |
| 5 | Reporting | Everything above | cmf report markdown/JSON/CSV: traceability matrices, API inventory, mermaid diagrams |
Build-time reporters |
Stages 0–1 run on every keystroke in the IDE (incremental). Stages 2–3 emit on every successful build. Stages 4–5 are opt-in: they run on cmf generate --reports or in CI.
Stage 2 Walkthrough — Order Aggregate
Take a single attributed declaration:
[AggregateRoot("Order", BoundedContext = "Ordering")]
[HasWorkflow("Fulfillment")]
public partial class Order
{
[EntityId] public partial OrderId Id { get; }
[Property("PlacedAt", Required = true)] public partial DateTime PlacedAt { get; }
[Property("Status", Required = true)] public partial OrderStatus Status { get; }
[Composition] public partial IReadOnlyList<OrderLine> Lines { get; }
[Composition] public partial ShippingAddress ShippingAddress { get; }
[Association] public partial CustomerId CustomerId { get; }
[Invariant("OrderMustHaveAtLeastOneLine")]
private Result EnsureHasLines() =>
Lines.Any() ? Result.Success() : Result.Failure("ORD-001: Order must have at least one line");
}[AggregateRoot("Order", BoundedContext = "Ordering")]
[HasWorkflow("Fulfillment")]
public partial class Order
{
[EntityId] public partial OrderId Id { get; }
[Property("PlacedAt", Required = true)] public partial DateTime PlacedAt { get; }
[Property("Status", Required = true)] public partial OrderStatus Status { get; }
[Composition] public partial IReadOnlyList<OrderLine> Lines { get; }
[Composition] public partial ShippingAddress ShippingAddress { get; }
[Association] public partial CustomerId CustomerId { get; }
[Invariant("OrderMustHaveAtLeastOneLine")]
private Result EnsureHasLines() =>
Lines.Any() ? Result.Success() : Result.Failure("ORD-001: Order must have at least one line");
}Stage 2 emits eleven files from this single declaration:
generated/Ordering/
├── Order.Implementation.g.cs (entity backing fields, invariant runner)
├── Order.Builder.g.cs (fluent builder with Result<Order>)
├── OrderId.g.cs (typed ID value object + JSON converter)
├── OrderLine.Implementation.g.cs (composed entity, owned-type)
├── ShippingAddress.g.cs (value object, IEquatable, ToString)
├── OrderConfiguration.g.cs (IEntityTypeConfiguration<Order>)
├── OrderLineConfiguration.g.cs (owned-type configuration)
├── OrderRepository.g.cs (IOrderRepository + EF implementation)
├── OrderingDbContext.Order.g.cs (DbSet partial, model registration)
├── CreateOrderCommand.g.cs (record + handler + validator)
└── OrderStatusChangedEvent.g.cs (domain event for the workflow)generated/Ordering/
├── Order.Implementation.g.cs (entity backing fields, invariant runner)
├── Order.Builder.g.cs (fluent builder with Result<Order>)
├── OrderId.g.cs (typed ID value object + JSON converter)
├── OrderLine.Implementation.g.cs (composed entity, owned-type)
├── ShippingAddress.g.cs (value object, IEquatable, ToString)
├── OrderConfiguration.g.cs (IEntityTypeConfiguration<Order>)
├── OrderLineConfiguration.g.cs (owned-type configuration)
├── OrderRepository.g.cs (IOrderRepository + EF implementation)
├── OrderingDbContext.Order.g.cs (DbSet partial, model registration)
├── CreateOrderCommand.g.cs (record + handler + validator)
└── OrderStatusChangedEvent.g.cs (domain event for the workflow)Each file is small, predictable, and <auto-generated/> so it never appears in human review. The interesting content is in three places:
1. The invariant runner. The [Invariant] method is wrapped so it executes on every state mutation, not just at construction:
// Order.Implementation.g.cs (excerpt)
public sealed partial class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
private Result CheckInvariants()
{
var r = EnsureHasLines();
if (!r.IsSuccess) return r;
// ...other invariants chained here
return Result.Success();
}
internal Result MutateAndCheck(Action mutation)
{
mutation();
return CheckInvariants();
}
}// Order.Implementation.g.cs (excerpt)
public sealed partial class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
private Result CheckInvariants()
{
var r = EnsureHasLines();
if (!r.IsSuccess) return r;
// ...other invariants chained here
return Result.Success();
}
internal Result MutateAndCheck(Action mutation)
{
mutation();
return CheckInvariants();
}
}2. The EF Core configuration, derived purely from [Composition] semantics — no OnModelCreating boilerplate:
// OrderConfiguration.g.cs
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasKey("_id");
b.Property<OrderId>("_id").HasConversion(v => v.Value, v => new OrderId(v));
b.Property<DateTime>("_placedAt").HasColumnName("PlacedAt").IsRequired();
b.Property<OrderStatus>("_status").HasColumnName("Status").HasConversion<string>().IsRequired();
b.OwnsMany<OrderLine>("_lines", o => { /* configured by OrderLineConfiguration */ });
b.OwnsOne<ShippingAddress>("_shippingAddress");
b.Property<Guid>("_customerId").HasColumnName("CustomerId");
}
}// OrderConfiguration.g.cs
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasKey("_id");
b.Property<OrderId>("_id").HasConversion(v => v.Value, v => new OrderId(v));
b.Property<DateTime>("_placedAt").HasColumnName("PlacedAt").IsRequired();
b.Property<OrderStatus>("_status").HasColumnName("Status").HasConversion<string>().IsRequired();
b.OwnsMany<OrderLine>("_lines", o => { /* configured by OrderLineConfiguration */ });
b.OwnsOne<ShippingAddress>("_shippingAddress");
b.Property<Guid>("_customerId").HasColumnName("CustomerId");
}
}3. The command + handler + validator bundle. A [Command] would do this for an explicit user-defined command, but the AggregateRoot also gets a default Create*Command generated automatically when the constructor is empty:
// CreateOrderCommand.g.cs
public sealed record CreateOrderCommand(
DateTime PlacedAt,
OrderStatus Status,
IReadOnlyList<CreateOrderLineDto> Lines,
ShippingAddress ShippingAddress,
CustomerId CustomerId);
public sealed class CreateOrderCommandHandler
: ICommandHandler<CreateOrderCommand, Result<Order>>
{
private readonly IOrderRepository _repo;
private readonly IEventBus _events;
public CreateOrderCommandHandler(IOrderRepository repo, IEventBus events)
{ _repo = repo; _events = events; }
public async Task<Result<Order>> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var built = new Order.Builder()
.WithPlacedAt(cmd.PlacedAt)
.WithStatus(cmd.Status)
.WithShippingAddress(cmd.ShippingAddress)
.WithCustomerId(cmd.CustomerId)
.WithLines(cmd.Lines.Select(l => l.ToEntity()))
.Build();
if (!built.IsSuccess) return built;
await _repo.SaveAsync(built.Value, ct);
await _events.PublishAsync(new OrderPlacedEvent(built.Value.Id), ct);
return built;
}
}// CreateOrderCommand.g.cs
public sealed record CreateOrderCommand(
DateTime PlacedAt,
OrderStatus Status,
IReadOnlyList<CreateOrderLineDto> Lines,
ShippingAddress ShippingAddress,
CustomerId CustomerId);
public sealed class CreateOrderCommandHandler
: ICommandHandler<CreateOrderCommand, Result<Order>>
{
private readonly IOrderRepository _repo;
private readonly IEventBus _events;
public CreateOrderCommandHandler(IOrderRepository repo, IEventBus events)
{ _repo = repo; _events = events; }
public async Task<Result<Order>> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var built = new Order.Builder()
.WithPlacedAt(cmd.PlacedAt)
.WithStatus(cmd.Status)
.WithShippingAddress(cmd.ShippingAddress)
.WithCustomerId(cmd.CustomerId)
.WithLines(cmd.Lines.Select(l => l.ToEntity()))
.Build();
if (!built.IsSuccess) return built;
await _repo.SaveAsync(built.Value, ct);
await _events.PublishAsync(new OrderPlacedEvent(built.Value.Id), ct);
return built;
}
}Stage 3 Walkthrough — UI & API Surface for Order
Now layer in three more attributes:
[AdminModule("Orders", Aggregate = "Order", Icon = "shopping-bag", MenuGroup = "Sales")]
[AdminAction("MarkShipped", Command = "MarkOrderShipped", RequiresRole = "Fulfillment")]
public partial class OrderAdminModule { }
[PageWidget("OrderConfirmation", Module = "Order",
DisplayType = DisplayType.Show, HasPage = true)]
public partial class OrderConfirmationWidget { }[AdminModule("Orders", Aggregate = "Order", Icon = "shopping-bag", MenuGroup = "Sales")]
[AdminAction("MarkShipped", Command = "MarkOrderShipped", RequiresRole = "Fulfillment")]
public partial class OrderAdminModule { }
[PageWidget("OrderConfirmation", Module = "Order",
DisplayType = DisplayType.Show, HasPage = true)]
public partial class OrderConfirmationWidget { }Stage 3 adds eight more files:
generated/Ordering/
├── OrderList.razor.g.cs (paginated list with filters)
├── OrderForm.razor.g.cs (auto-generated form, grouped fields)
├── OrderDetail.razor.g.cs (detail view + action buttons)
├── OrderAdminMenu.g.cs (menu entry with icon, group, role)
├── OrderConfirmationWidget.razor.g.cs (Blazor WASM component)
├── OrdersController.g.cs (REST controller: GET/POST/PUT/DELETE)
├── OrdersGraphQLType.g.cs (HotChocolate ObjectType + Query field)
└── OrdersOpenApi.g.cs (Swashbuckle filter with [Implements] tags)generated/Ordering/
├── OrderList.razor.g.cs (paginated list with filters)
├── OrderForm.razor.g.cs (auto-generated form, grouped fields)
├── OrderDetail.razor.g.cs (detail view + action buttons)
├── OrderAdminMenu.g.cs (menu entry with icon, group, role)
├── OrderConfirmationWidget.razor.g.cs (Blazor WASM component)
├── OrdersController.g.cs (REST controller: GET/POST/PUT/DELETE)
├── OrdersGraphQLType.g.cs (HotChocolate ObjectType + Query field)
└── OrdersOpenApi.g.cs (Swashbuckle filter with [Implements] tags)A representative Stage 3 file is OrdersController.g.cs:
[ApiController]
[Route("api/orders")]
public sealed partial class OrdersController : ControllerBase
{
private readonly ICommandBus _commands;
private readonly IQueryBus _queries;
public OrdersController(ICommandBus commands, IQueryBus queries)
{ _commands = commands; _queries = queries; }
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(OrderDto), 200)]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var result = await _queries.AskAsync(new GetOrderQuery(new OrderId(id)), ct);
return result.Match<IActionResult>(Ok, NotFound);
}
[HttpPost]
[ProducesResponseType(typeof(OrderDto), 201)]
[ProducesResponseType(typeof(ProblemDetails), 400)]
public async Task<IActionResult> Create([FromBody] CreateOrderCommand cmd, CancellationToken ct)
{
var result = await _commands.SendAsync(cmd, ct);
return result.Match<IActionResult>(
order => CreatedAtAction(nameof(Get), new { id = order.Id.Value }, OrderDto.From(order)),
failure => Problem(failure.Message, statusCode: 400));
}
}[ApiController]
[Route("api/orders")]
public sealed partial class OrdersController : ControllerBase
{
private readonly ICommandBus _commands;
private readonly IQueryBus _queries;
public OrdersController(ICommandBus commands, IQueryBus queries)
{ _commands = commands; _queries = queries; }
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(OrderDto), 200)]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
var result = await _queries.AskAsync(new GetOrderQuery(new OrderId(id)), ct);
return result.Match<IActionResult>(Ok, NotFound);
}
[HttpPost]
[ProducesResponseType(typeof(OrderDto), 201)]
[ProducesResponseType(typeof(ProblemDetails), 400)]
public async Task<IActionResult> Create([FromBody] CreateOrderCommand cmd, CancellationToken ct)
{
var result = await _commands.SendAsync(cmd, ct);
return result.Match<IActionResult>(
order => CreatedAtAction(nameof(Get), new { id = order.Id.Value }, OrderDto.From(order)),
failure => Problem(failure.Message, statusCode: 400));
}
}Artifact Inventory Table
For the Order aggregate above plus the admin and widget attributes, here is the complete inventory the developer never has to write:
| Stage | File | Purpose | Approx. lines |
|---|---|---|---|
| 2 | Order.Implementation.g.cs |
Backing fields, invariants, mutators | 110 |
| 2 | Order.Builder.g.cs |
Fluent builder returning Result<Order> |
90 |
| 2 | OrderId.g.cs |
Strongly-typed ID + JSON converter | 35 |
| 2 | OrderLine.Implementation.g.cs |
Owned entity | 60 |
| 2 | ShippingAddress.g.cs |
Value object | 50 |
| 2 | OrderConfiguration.g.cs |
EF Core mapping | 40 |
| 2 | OrderLineConfiguration.g.cs |
Owned-type mapping | 25 |
| 2 | OrderRepository.g.cs |
Repository interface + EF impl | 70 |
| 2 | OrderingDbContext.Order.g.cs |
DbSet partial | 15 |
| 2 | CreateOrderCommand.g.cs |
Command + handler + validator | 95 |
| 2 | OrderStatusChangedEvent.g.cs |
Domain event | 25 |
| 3 | OrderList.razor.g.cs |
Admin list view | 130 |
| 3 | OrderForm.razor.g.cs |
Admin form | 145 |
| 3 | OrderDetail.razor.g.cs |
Admin detail + actions | 90 |
| 3 | OrderAdminMenu.g.cs |
Menu entry | 20 |
| 3 | OrderConfirmationWidget.razor.g.cs |
Public widget | 65 |
| 3 | OrdersController.g.cs |
REST controller | 120 |
| 3 | OrdersGraphQLType.g.cs |
GraphQL type + query | 80 |
| 3 | OrdersOpenApi.g.cs |
OpenAPI metadata | 40 |
| 4 | OrderWorkflow.StateMachine.g.cs |
From [HasWorkflow("Fulfillment")] |
110 |
| 4 | OrderSearchIndex.g.cs |
If [HasPart("Searchable")] is added |
30 |
| Total | 21 files | ≈1,445 |
Before / After
The hand-written input is the partial class declaration plus three small attributed marker classes — roughly 35 lines of C# counting whitespace. The compiler turns this into 21 files / ≈1,445 lines covering the domain model, persistence, admin UI, public widgets, REST, GraphQL, OpenAPI, and the workflow state machine.
hand-written: 35 lines ████
generated: 1,445 lines ████████████████████████████████████████████████████████████hand-written: 35 lines ████
generated: 1,445 lines ████████████████████████████████████████████████████████████The ratio is roughly 1 : 41. More importantly, the kinds of code that disappear are exactly the kinds that drift: ORM mappings, controller boilerplate, DTO converters, Razor scaffolding, OpenAPI annotations. None of those have to be reviewed in PRs because none of them are written by humans.
What the Generator Does Not Generate
Equally important is the negative space. The compiler will not emit:
- Domain logic. Invariants, command bodies, saga steps, and aggregate behavior remain hand-written. The generator only emits the plumbing around them.
- UI customizations beyond conventions. If a form needs a custom widget for a
Moneyfield, the developer overrides the partial Razor file. The generator regenerates the rest. - Authorization policies.
[RequiresRole]declares the requirement, but the policy itself lives inStartup.cs(covered in Part 16: Security, Authorization & Audit). - Migration scripts. EF Core's
dotnet ef migrations addis invoked bycmf migrate(Part 11) — the generator emits the configuration, not the migration history. - Tests. Tests are hand-written but discovered by
[TestFor]attributes for coverage analysis (Part 15: Testing Strategy).
Regeneration Semantics
Generated files live in obj/Generated/ and are never checked in. Three rules govern regeneration:
- Pure functions of the input. Given the same attributed declarations and the same DSL version, the generator emits byte-for-byte identical output. This makes generated code reviewable indirectly: PR diffs show only the attribute changes, not the cascade of
.g.csupdates. - Incremental. Roslyn's
IIncrementalGeneratoronly re-runs the stages whose inputs have changed. Editing a[Property]description regenerates one file; adding a new aggregate regenerates that aggregate's eleven Stage 2 files but leaves the rest of the project untouched. - No round-trip. The generator never reads its own output. If a developer hand-edits a
.g.csfile, the next build silently overwrites it. Customizations go inpartialsiblings, never in the generated files themselves.
These rules are enforced by analyzers CMF101 (no edits to files under obj/Generated/) and CMF102 (no .g.cs files in git).