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

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:

Diagram
From a single Product aggregate carrying a handful of attributes, four generators fan out and emit the entire stack — domain, EF configuration, admin screens and page widgets — without a line of glue code.

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

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

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)

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

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

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

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

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)

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

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  ████████████████████████████████████████████████████████████

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 Money field, the developer overrides the partial Razor file. The generator regenerates the rest.
  • Authorization policies. [RequiresRole] declares the requirement, but the policy itself lives in Startup.cs (covered in Part 16: Security, Authorization & Audit).
  • Migration scripts. EF Core's dotnet ef migrations add is invoked by cmf 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:

  1. 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.cs updates.
  2. Incremental. Roslyn's IIncrementalGenerator only 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.
  3. No round-trip. The generator never reads its own output. If a developer hand-edits a .g.cs file, the next build silently overwrites it. Customizations go in partial siblings, 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).

⬇ Download