Complete Example -- E-Commerce
Three bounded contexts, multiple aggregates, content parts, blocks, widgets, and a workflow:
// ── Catalog Context ──
[AggregateRoot("Product", BoundedContext = "Catalog")]
[HasPart("Routable")][HasPart("Seoable")][HasPart("Taggable")]
[HasPart("Searchable")][HasWorkflow("Editorial")]
public partial class Product
{
[EntityId] public partial ProductId Id { get; }
[Property("Name", Required = true)][SearchField(Boost = 2.0f)]
public partial string Name { get; }
[Property("Sku", Required = true)] public partial string Sku { get; }
[Composition] public partial Money Price { get; }
[StreamField("Details", StreamBlock = "ProductContent")]
public partial ProductContentStream Details { get; }
[Composition] public partial IReadOnlyList<ProductVariant> Variants { get; }
[Association] public partial CategoryId CategoryId { get; }
}
// ── Ordering Context ──
[AggregateRoot("Order", BoundedContext = "Ordering")]
public partial class Order
{
[EntityId] public partial OrderId Id { 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; }
}
// ── Shipping Context ──
[AggregateRoot("Shipment", BoundedContext = "Shipping")]
public partial class Shipment
{
[EntityId] public partial ShipmentId Id { get; }
[Association] public partial OrderId OrderId { get; }
[Composition] public partial TrackingNumber TrackingNumber { get; }
}// ── Catalog Context ──
[AggregateRoot("Product", BoundedContext = "Catalog")]
[HasPart("Routable")][HasPart("Seoable")][HasPart("Taggable")]
[HasPart("Searchable")][HasWorkflow("Editorial")]
public partial class Product
{
[EntityId] public partial ProductId Id { get; }
[Property("Name", Required = true)][SearchField(Boost = 2.0f)]
public partial string Name { get; }
[Property("Sku", Required = true)] public partial string Sku { get; }
[Composition] public partial Money Price { get; }
[StreamField("Details", StreamBlock = "ProductContent")]
public partial ProductContentStream Details { get; }
[Composition] public partial IReadOnlyList<ProductVariant> Variants { get; }
[Association] public partial CategoryId CategoryId { get; }
}
// ── Ordering Context ──
[AggregateRoot("Order", BoundedContext = "Ordering")]
public partial class Order
{
[EntityId] public partial OrderId Id { 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; }
}
// ── Shipping Context ──
[AggregateRoot("Shipment", BoundedContext = "Shipping")]
public partial class Shipment
{
[EntityId] public partial ShipmentId Id { get; }
[Association] public partial OrderId OrderId { get; }
[Composition] public partial TrackingNumber TrackingNumber { get; }
}Page widgets and admin modules:
[PageWidget("ProductList", Module = "Product", DisplayType = DisplayType.List)]
public partial class ProductListWidget
{
[WidgetFilter("Category")] public partial CategoryId? CategoryFilter { get; }
[WidgetConfig("MaxPerPage", DefaultValue = 12)] public partial int MaxPerPage { get; }
}
[PageWidget("ProductShow", Module = "Product",
DisplayType = DisplayType.Show, HasPage = true)]
public partial class ProductShowWidget { }
[AdminModule("Products", Aggregate = "Product", Icon = "package", MenuGroup = "Catalog")]
[AdminAction("Publish", Command = "PublishProduct")]
public partial class ProductAdminModule { }[PageWidget("ProductList", Module = "Product", DisplayType = DisplayType.List)]
public partial class ProductListWidget
{
[WidgetFilter("Category")] public partial CategoryId? CategoryFilter { get; }
[WidgetConfig("MaxPerPage", DefaultValue = 12)] public partial int MaxPerPage { get; }
}
[PageWidget("ProductShow", Module = "Product",
DisplayType = DisplayType.Show, HasPage = true)]
public partial class ProductShowWidget { }
[AdminModule("Products", Aggregate = "Product", Icon = "package", MenuGroup = "Catalog")]
[AdminAction("Publish", Command = "PublishProduct")]
public partial class ProductAdminModule { }Runtime page tree built by a content editor:
/ (Home) Layout: FullWidth
├── /catalog Layout: TwoColumn
│ ├── Zone "content": [ProductList(MaxPerPage=12)]
│ └── Zone "sidebar": [CategoryNav]
├── /catalog/electronics Layout: TwoColumn ← bound to Category
│ └── Zone "content": [ProductList(CategoryFilter=electronics)]
├── /catalog/electronics/laptop-x1 Layout: ProductDetail ← bound to Product
│ └── Zone "content": [ProductShow]
└── /about Layout: FullWidth
└── Zone "content": [StreamField(PageContent)]/ (Home) Layout: FullWidth
├── /catalog Layout: TwoColumn
│ ├── Zone "content": [ProductList(MaxPerPage=12)]
│ └── Zone "sidebar": [CategoryNav]
├── /catalog/electronics Layout: TwoColumn ← bound to Category
│ └── Zone "content": [ProductList(CategoryFilter=electronics)]
├── /catalog/electronics/laptop-x1 Layout: ProductDetail ← bound to Product
│ └── Zone "content": [ProductShow]
└── /about Layout: FullWidth
└── Zone "content": [StreamField(PageContent)]The previous sections show the what. The rest of this part shows the how: a complete walkthrough from cmf new to a running storefront with admin, page tree, workflow, and requirements coverage report. Every command is real, every generated artifact is named, and every screen described is rendered by code the developer never wrote.
Day 0: Scaffold
$ cmf new AcmeStore --providers postgres,redis --client wasm
✓ Solution AcmeStore.sln created
✓ 7 projects scaffolded
✓ docker-compose.yml created (postgres:16, redis:7)
✓ .config/dotnet-tools.json pinned to cmf 1.4.2
✓ git initialized
$ cd AcmeStore
$ docker compose up -d
$ cmf doctor
All checks passed.$ cmf new AcmeStore --providers postgres,redis --client wasm
✓ Solution AcmeStore.sln created
✓ 7 projects scaffolded
✓ docker-compose.yml created (postgres:16, redis:7)
✓ .config/dotnet-tools.json pinned to cmf 1.4.2
✓ git initialized
$ cd AcmeStore
$ docker compose up -d
$ cmf doctor
All checks passed.The solution compiles immediately even though it contains zero domain code. The reason is that the generators only emit when they discover attributed declarations — an empty Lib means an empty obj/Generated/, which is valid.
Day 1: First Bounded Context
$ cmf add bounded-context Catalog
$ cmf add aggregate Category --context Catalog
$ cmf add aggregate Product --context Catalog$ cmf add bounded-context Catalog
$ cmf add aggregate Category --context Catalog
$ cmf add aggregate Product --context CatalogThe developer now opens src/AcmeStore.Lib/Catalog/Aggregates/Product.cs and replaces the stub with the declaration shown in the previous section. They do the same for Category, then run:
$ cmf generate
Stage 0 Discovery 2 aggregates, 0 widgets, 0 workflows, 0 requirements
Stage 1 Validation 0 errors
Stage 2 Domain 18 files
Stage 3 UI/API 0 files ← no admin/widget yet
Total 18 files in 2.1s$ cmf generate
Stage 0 Discovery 2 aggregates, 0 widgets, 0 workflows, 0 requirements
Stage 1 Validation 0 errors
Stage 2 Domain 18 files
Stage 3 UI/API 0 files ← no admin/widget yet
Total 18 files in 2.1scmf migrate is the next step. It introspects the generated CatalogDbContext, detects that the schema has no migrations yet, and creates the initial one:
$ cmf migrate
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
✓ Generated migration: 20260319_Initial_Catalog
✓ Files: src/AcmeStore.Infrastructure.Postgres/Migrations/20260319_Initial_Catalog.cs
src/AcmeStore.Infrastructure.Postgres/Migrations/20260319_Initial_Catalog.Designer.cs
src/AcmeStore.Infrastructure.Postgres/Migrations/CatalogDbContextModelSnapshot.cs
$ cmf migrate --apply
Applying migration '20260319_Initial_Catalog'.
Done.$ cmf migrate
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
✓ Generated migration: 20260319_Initial_Catalog
✓ Files: src/AcmeStore.Infrastructure.Postgres/Migrations/20260319_Initial_Catalog.cs
src/AcmeStore.Infrastructure.Postgres/Migrations/20260319_Initial_Catalog.Designer.cs
src/AcmeStore.Infrastructure.Postgres/Migrations/CatalogDbContextModelSnapshot.cs
$ cmf migrate --apply
Applying migration '20260319_Initial_Catalog'.
Done.At this point the database has catalog.products, catalog.categories, catalog.product_variants, and the columns derived from [Property] and [Composition] declarations. No OnModelCreating was hand-written.
Day 2: Admin & First REST Endpoint
The developer adds the admin module and runs the server:
$ cmf add admin-module Products --aggregate Product
$ cmf generate
Stage 3 UI/API 7 files
...
$ dotnet run --project src/AcmeStore.Server
Now listening on: https://localhost:5001$ cmf add admin-module Products --aggregate Product
$ cmf generate
Stage 3 UI/API 7 files
...
$ dotnet run --project src/AcmeStore.Server
Now listening on: https://localhost:5001Opening https://localhost:5001/admin/products shows a fully functional admin grid, even though no Razor was hand-written. Behind the scenes the Stage 3 outputs that became live:
| File | Renders |
|---|---|
ProductList.razor.g.cs |
The grid with sortable columns derived from [Property] |
ProductForm.razor.g.cs |
The create/edit form, with Money and StreamField widgets |
ProductDetail.razor.g.cs |
The detail view with action buttons |
ProductsController.g.cs |
GET /api/products, GET /api/products/{id}, POST/PUT/DELETE |
ProductsGraphQLType.g.cs |
A Query.products field on the GraphQL schema |
ProductAdminMenu.g.cs |
Sidebar entry under "Catalog" with the package icon |
The form includes inline validation for Required = true properties because the FluentValidation rule for CreateProductCommand runs on the client-side EditForm (the kernel rule from Part 10).
Day 3: Public Storefront and Page Tree
$ cmf add widget ProductList --aggregate Product --display List
$ cmf add widget ProductShow --aggregate Product --display Show --has-page
$ cmf generate$ cmf add widget ProductList --aggregate Product --display List
$ cmf add widget ProductShow --aggregate Product --display Show --has-page
$ cmf generateThe two widgets become Blazor WebAssembly components in AcmeStore.Client. The crucial flag is --has-page on ProductShow: it tells the Pages generator that every Product instance should automatically materialize as a page in the page tree, with a URL derived from [HasPart("Routable")].
A content editor logs into /admin, navigates to Pages, and builds the tree shown earlier. The page tree is just data — rows in pages.page and pages.zone_widgets — so the editor can rearrange it without a deploy. When a visitor hits /catalog/electronics/laptop-x1, the runtime walks the tree, resolves the page, instantiates the ProductShow widget, looks up the bound Product, and renders the result. The relevant runtime hops:
GET /catalog/electronics/laptop-x1
└─▶ DynamicRouter (Pages DSL)
├─ resolves materialized path "/catalog/electronics/laptop-x1" → PageId 4711
├─ loads Page 4711 with Layout=ProductDetail
└─▶ LayoutRenderer
└─ Zone "content"
└─ Widget ProductShow(boundEntityId=Product/laptop-x1)
└─▶ ProductShowWidget.razor (WASM)
├─ fetches GET /api/products/laptop-x1
└─ renders with Hero/RichText/Pricing blocksGET /catalog/electronics/laptop-x1
└─▶ DynamicRouter (Pages DSL)
├─ resolves materialized path "/catalog/electronics/laptop-x1" → PageId 4711
├─ loads Page 4711 with Layout=ProductDetail
└─▶ LayoutRenderer
└─ Zone "content"
└─ Widget ProductShow(boundEntityId=Product/laptop-x1)
└─▶ ProductShowWidget.razor (WASM)
├─ fetches GET /api/products/laptop-x1
└─ renders with Hero/RichText/Pricing blocksThe browser ends up with a SPA shell from AcmeStore.Client and live data from AcmeStore.Server — both compiled against the same OrderDto/ProductDto types from AcmeStore.Shared.
Day 4: Workflow
The marketing team requires that every product be reviewed before it goes live. The developer adds:
[Workflow("Editorial", AppliesTo = nameof(Product))]
public partial class EditorialWorkflow
{
[Stage("Draft", IsInitial = true)] public partial WorkflowStage Draft { get; }
[Stage("Review")] public partial WorkflowStage Review { get; }
[Stage("Approved")] public partial WorkflowStage Approved { get; }
[Stage("Published", IsTerminal = true)] public partial WorkflowStage Published { get; }
[Transition(From = "Draft", To = "Review", Label = "Submit for review")]
[RequiresRole("Editor")]
public partial Result Submit(Product p);
[Transition(From = "Review", To = "Approved", Label = "Approve")]
[RequiresRole("Reviewer")]
[Gate("AllAcceptanceCriteriaMet")]
public partial Result Approve(Product p);
[Transition(From = "Approved", To = "Published", Label = "Publish")]
[RequiresRole("Publisher")]
public partial Result Publish(Product p);
}[Workflow("Editorial", AppliesTo = nameof(Product))]
public partial class EditorialWorkflow
{
[Stage("Draft", IsInitial = true)] public partial WorkflowStage Draft { get; }
[Stage("Review")] public partial WorkflowStage Review { get; }
[Stage("Approved")] public partial WorkflowStage Approved { get; }
[Stage("Published", IsTerminal = true)] public partial WorkflowStage Published { get; }
[Transition(From = "Draft", To = "Review", Label = "Submit for review")]
[RequiresRole("Editor")]
public partial Result Submit(Product p);
[Transition(From = "Review", To = "Approved", Label = "Approve")]
[RequiresRole("Reviewer")]
[Gate("AllAcceptanceCriteriaMet")]
public partial Result Approve(Product p);
[Transition(From = "Approved", To = "Published", Label = "Publish")]
[RequiresRole("Publisher")]
public partial Result Publish(Product p);
}After cmf generate, three things change:
- Every
Productrow gains aWorkflowStatecolumn, populated by the EF migration thatcmf migrateproduces automatically. - The admin detail view gains action buttons for
Submit,Approve,Publish— but only if the current user has the matching role and the gate evaluates to true. The buttons are not custom-coded; the Admin generator reads[Transition]and[RequiresRole]and emits them. - A REST endpoint
POST /api/products/{id}/workflow/transitionappears inProductsController.g.cs, accepting aTransitionNameand returning aResultwith the new state.
A typical run-through, executed by three different users:
User=alice role=Editor → POST /api/products/laptop-x1/workflow/transition { "name": "Submit" }
→ 200 OK { "from": "Draft", "to": "Review" }
User=bob role=Reviewer → POST /api/products/laptop-x1/workflow/transition { "name": "Approve" }
→ 422 Unprocessable Entity
{ "code": "GATE-001", "message": "Acceptance criterion AC.2 is not satisfied" }
← Bob fixes the missing field via the form, then retries
→ 200 OK { "from": "Review", "to": "Approved" }
User=carol role=Publisher → POST /api/products/laptop-x1/workflow/transition { "name": "Publish" }
→ 200 OK { "from": "Approved", "to": "Published" }User=alice role=Editor → POST /api/products/laptop-x1/workflow/transition { "name": "Submit" }
→ 200 OK { "from": "Draft", "to": "Review" }
User=bob role=Reviewer → POST /api/products/laptop-x1/workflow/transition { "name": "Approve" }
→ 422 Unprocessable Entity
{ "code": "GATE-001", "message": "Acceptance criterion AC.2 is not satisfied" }
← Bob fixes the missing field via the form, then retries
→ 200 OK { "from": "Review", "to": "Approved" }
User=carol role=Publisher → POST /api/products/laptop-x1/workflow/transition { "name": "Publish" }
→ 200 OK { "from": "Approved", "to": "Published" }Every transition fires an OrderStatusChangedEvent (or ProductWorkflowChangedEvent here) that the workflow engine logs to the audit table. The audit log is examined further in Part 16: Security, Authorization & Audit.
Day 5: Requirements & Coverage
Before any of this code shipped, the product manager wrote three features in src/AcmeStore.Lib/Requirements/Catalog.cs:
[Feature(Id = "FEATURE-101", Title = "Editors can publish products", Owner = "platform",
AcceptanceCriteria = new[] {
"Drafts cannot be published without review",
"Only users with the Publisher role can publish"
})]
public partial class EditorsCanPublishProducts { }
[Feature(Id = "FEATURE-102", Title = "Public storefront shows published products only",
AcceptanceCriteria = new[] {
"Published products are visible at their permalink",
"Draft products return 404"
})]
public partial class StorefrontShowsPublishedOnly { }
[Feature(Id = "FEATURE-103", Title = "Products have at least one variant",
AcceptanceCriteria = new[] { "Cannot save a product with zero variants" })]
public partial class ProductsHaveVariants { }[Feature(Id = "FEATURE-101", Title = "Editors can publish products", Owner = "platform",
AcceptanceCriteria = new[] {
"Drafts cannot be published without review",
"Only users with the Publisher role can publish"
})]
public partial class EditorsCanPublishProducts { }
[Feature(Id = "FEATURE-102", Title = "Public storefront shows published products only",
AcceptanceCriteria = new[] {
"Published products are visible at their permalink",
"Draft products return 404"
})]
public partial class StorefrontShowsPublishedOnly { }
[Feature(Id = "FEATURE-103", Title = "Products have at least one variant",
AcceptanceCriteria = new[] { "Cannot save a product with zero variants" })]
public partial class ProductsHaveVariants { }The developer attached [Implements] to the relevant code paths (the Publish transition, the dynamic router, the Product invariant). After the build, cmf report requirements produces this excerpt:
$ cmf report requirements
✓ artifacts/reports/requirements.md
Coverage summary
────────────────
Total features 3
Implemented 3 (100%)
Tested 2 (67%) ⚠ FEATURE-103 has no [TestFor]
Lifecycle violations 0
By owner
────────
platform 3 features | 100% impl | 67% test$ cmf report requirements
✓ artifacts/reports/requirements.md
Coverage summary
────────────────
Total features 3
Implemented 3 (100%)
Tested 2 (67%) ⚠ FEATURE-103 has no [TestFor]
Lifecycle violations 0
By owner
────────
platform 3 features | 100% impl | 67% testThe ⚠ is significant because cmf validate is configured (in cmfconfig.json) to fail the build if test coverage drops below 80%. The build is now red. The fix is to add a test:
[TestFor("FEATURE-103")]
public class ProductMustHaveVariantsTests
{
[Fact]
public void Building_a_product_with_no_variants_returns_failure()
{
var result = new Product.Builder().WithName("X").WithSku("X").Build();
result.IsSuccess.Should().BeFalse();
result.AsFailure().Code.Should().Be("CAT-014");
}
}[TestFor("FEATURE-103")]
public class ProductMustHaveVariantsTests
{
[Fact]
public void Building_a_product_with_no_variants_returns_failure()
{
var result = new Product.Builder().WithName("X").WithSku("X").Build();
result.IsSuccess.Should().BeFalse();
result.AsFailure().Code.Should().Be("CAT-014");
}
}Re-running the build, coverage hits 100% and the gate passes. This is what "Requirements as Code" looks like in practice: a missing test isn't a process failure caught in retrospective, it's a compile error.
Day 6: Deploy
cmf does not deploy — that is the Ops DSL ecosystem's job — but the artifacts produced by Days 0–5 are everything a deploy needs:
artifacts/
├── server/
│ └── AcmeStore.Server.dll (ASP.NET host with all generated controllers)
├── client/
│ └── _framework/ (Blazor WASM bundle, ~1.8 MB after trimming)
├── migrations/
│ └── 20260319_*.sql (idempotent SQL produced by `dotnet ef migrations script`)
├── reports/
│ ├── requirements.md (the coverage matrix shown above)
│ ├── api-inventory.md (every endpoint, its requirement tags, its auth policy)
│ ├── openapi.json
│ └── mermaid/ (live aggregate, page-tree and workflow diagrams)
└── docker/
└── Dockerfile (multi-stage: build → trim → publish)artifacts/
├── server/
│ └── AcmeStore.Server.dll (ASP.NET host with all generated controllers)
├── client/
│ └── _framework/ (Blazor WASM bundle, ~1.8 MB after trimming)
├── migrations/
│ └── 20260319_*.sql (idempotent SQL produced by `dotnet ef migrations script`)
├── reports/
│ ├── requirements.md (the coverage matrix shown above)
│ ├── api-inventory.md (every endpoint, its requirement tags, its auth policy)
│ ├── openapi.json
│ └── mermaid/ (live aggregate, page-tree and workflow diagrams)
└── docker/
└── Dockerfile (multi-stage: build → trim → publish)A six-day arc has produced a typed full-stack storefront with admin, public site, workflow, audit, and requirements traceability. Every line of UI, persistence, validation, and API was emitted from forty-odd lines of attributed C# spread across the bounded contexts. The hand-written code is exclusively domain logic — invariants, transition guards, the few custom widgets that needed hand-tuning — and that is the only code that ever needed review.