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

Extending the M3 with a New DSL

The CMF ships with six DSLs because those six cover the recurring needs of every CMS-style application: domain modeling, content composition, admin UI, public pages, editorial workflow, and requirement tracing. Most projects need only the six. Some projects — typically the ones with strong regulatory, multi-tenant, or domain-specific extensions — benefit from a seventh. This part shows how a team adds one without modifying the CMF itself.

The M3 meta-metamodel was designed for this. The same primitives that the six built-in DSLs use (MetaConcept, MetaProperty, MetaReference, MetaConstraint, MetaInherits, documented in modeling.md) are public, stable, and consumable by any package that references Cmf.Lib.Abstractions. A new DSL is a NuGet package, not a fork.

Anatomy of a DSL Package

A DSL is three things in a trench coat: a set of attributes, a generator that consumes them, and analyzers that enforce the rules the generator depends on. The package layout is fixed by convention so cmf doctor can discover it:

Acme.Cmf.Permissions/
├── Acme.Cmf.Permissions.Lib/                     (the attributes — referenced by user code)
│   ├── PermissionAttribute.cs
│   ├── HasPermissionAttribute.cs
│   └── PolicyAttribute.cs
├── Acme.Cmf.Permissions.Generators/              (the IIncrementalGenerator host)
│   ├── PermissionDiscoveryStage.cs
│   ├── PermissionGenerator.cs
│   └── Templates/
│       └── PermissionRegistry.scriban
├── Acme.Cmf.Permissions.Analyzers/               (the diagnostics)
│   ├── PERM101_DuplicatePermission.cs
│   └── PERM201_OrphanedHasPermission.cs
├── Acme.Cmf.Permissions.Lib.Testing/             (test fixtures, generated and hand-written)
│   └── PermissionTestBuilder.cs
└── Acme.Cmf.Permissions.nuspec

A consumer adds the package with dotnet add package Acme.Cmf.Permissions and gets all four pieces transitively. The CMF runtime is unchanged.

The Five Stages, Now From a Plug-in's Perspective

The Stage 0–5 pipeline is the same regardless of who is generating. A third-party generator participates by registering hooks at the right stages:

Stage What a third-party generator can do API
0 Discovery Scan the compilation for its own attributes, build its slice of the model ctx.OnAttribute<PermissionAttribute>(handler)
1 Validation Emit diagnostics; cross-check with the rest of the model ctx.ReportDiagnostic(...)
2 Domain Emit C# files that participate in the M1 entity layer (e.g. interfaces, value objects) ctx.EmitFile(name, content)
3 UI/API Emit Blazor components, controllers, GraphQL types ctx.EmitFile(...) with the same API
4 Cross-cutting Aggregate across the whole model — the third-party generator can read built-in DSLs' outputs through ctx.Model ctx.Model.AllAggregates, ctx.Model.AllRequirements, etc.
5 Reporting Emit markdown / JSON to artifacts/reports/ ctx.EmitReport(name, content)

The contract is intentionally narrow. A generator cannot mutate the M1 model — only read it and emit additional files. This means a buggy third-party generator can produce wrong code but cannot corrupt the built-in pipeline, and cmf clean --generated always restores the project to a known state.

Case Study 1: A [Permission] DSL

Suppose the team needs fine-grained permissions beyond the built-in [RequiresRole]. They want to declare permissions as first-class entities ("ProductCreate", "ProductPublish", "OrderRefund"), assign them to roles, and reference them from any aggregate operation.

Step 1 — Attributes. Three attributes are enough:

[AttributeUsage(AttributeTargets.Class)]
public sealed class PermissionAttribute : Attribute
{
    public string Code { get; }
    public string Description { get; }
    public PermissionAttribute(string code, string description) {
        Code = code; Description = description;
    }
}

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class HasPermissionAttribute : Attribute
{
    public string Code { get; }
    public HasPermissionAttribute(string code) { Code = code; }
}

[AttributeUsage(AttributeTargets.Class)]
public sealed class PolicyAttribute : Attribute
{
    public string Name { get; }
    public string[] Permissions { get; }
    public PolicyAttribute(string name, params string[] permissions) {
        Name = name; Permissions = permissions;
    }
}

A consumer's M1 declarations look like this:

[Permission("Product.Create",  "Create new products")]
[Permission("Product.Publish", "Publish a product to the storefront")]
[Permission("Order.Refund",    "Issue a refund against an order")]
public partial class CatalogPermissions { }

[Policy("Editor",      "Product.Create")]
[Policy("Publisher",   "Product.Publish", "Product.Create")]
[Policy("Accountant",  "Order.Refund")]
public partial class StorePolicies { }

// And in the domain:
public partial class Product
{
    [HasPermission("Product.Publish")]
    public Result Publish(IClock clock) { /* ... */ }
}

Step 2 — Discovery (Stage 0). The generator collects the three attributes:

public sealed class PermissionDiscovery : ICmfDiscoveryStage
{
    public void Configure(CmfDiscoveryContext ctx)
    {
        ctx.OnAttribute<PermissionAttribute>(decl =>
            ctx.Model.AddOrUpdate("Permissions", decl.Symbol, new PermissionInfo(
                Code: decl.GetString("Code"),
                Description: decl.GetString("Description"),
                DeclaredIn: decl.SourceLocation)));

        ctx.OnAttribute<PolicyAttribute>(decl =>
            ctx.Model.AddOrUpdate("Policies", decl.Symbol, new PolicyInfo(
                Name: decl.GetString("Name"),
                Permissions: decl.GetStringArray("Permissions"))));

        ctx.OnAttribute<HasPermissionAttribute>(decl =>
            ctx.Model.AddOrUpdate("PermissionUsages", decl.Symbol, new PermissionUsage(
                Code: decl.GetString("Code"),
                On: decl.ContainingSymbol)));
    }
}

The ctx.Model dictionary is the third-party generator's slice of the M1 model. It is keyed by namespace so two generators cannot collide.

Step 3 — Validation (Stage 1). The generator emits diagnostics for the obvious failure modes:

public sealed class PermissionAnalyzer : ICmfValidationStage
{
    public void Validate(CmfValidationContext ctx)
    {
        var permissions = ctx.Model.Get<PermissionInfo>("Permissions").ToList();
        var duplicates = permissions.GroupBy(p => p.Code).Where(g => g.Count() > 1);
        foreach (var dup in duplicates)
            ctx.ReportDiagnostic("PERM101",
                $"Permission code '{dup.Key}' is declared more than once",
                dup.First().DeclaredIn);

        var declaredCodes = permissions.Select(p => p.Code).ToHashSet();
        foreach (var policy in ctx.Model.Get<PolicyInfo>("Policies"))
            foreach (var pcode in policy.Permissions.Where(p => !declaredCodes.Contains(p)))
                ctx.ReportDiagnostic("PERM102",
                    $"Policy '{policy.Name}' references unknown permission '{pcode}'",
                    policy.DeclaredIn);

        foreach (var usage in ctx.Model.Get<PermissionUsage>("PermissionUsages"))
            if (!declaredCodes.Contains(usage.Code))
                ctx.ReportDiagnostic("PERM201",
                    $"[HasPermission(\"{usage.Code}\")] references unknown permission",
                    usage.On.Locations[0]);
    }
}

These analyzers slot into the existing CMFxxx family because the same IDiagnosticReporter is used by every stage. The IDE squiggles look identical to the built-in diagnostics; the developer cannot tell which generator emitted them.

Step 4 — Domain emission (Stage 2). The generator produces a constant registry and a permissions catalog:

public sealed class PermissionGenerator : ICmfGenerationStage
{
    public void Generate(CmfGenerationContext ctx)
    {
        var permissions = ctx.Model.Get<PermissionInfo>("Permissions");
        var policies    = ctx.Model.Get<PolicyInfo>("Policies");

        // Const string registry — type-safe references
        ctx.EmitFile("Permissions.g.cs", PermissionRegistryTemplate.Render(permissions));
        // Authorization options — wire into ASP.NET Core DI
        ctx.EmitFile("PermissionAuthorization.g.cs", PolicyOptionsTemplate.Render(policies));
    }
}

The output of Permissions.g.cs is what consumers actually use:

public static class Permissions
{
    public const string Product_Create  = "Product.Create";
    public const string Product_Publish = "Product.Publish";
    public const string Order_Refund    = "Order.Refund";
}

So [HasPermission(Permissions.Product_Publish)] becomes typo-proof, refactor-safe, and IDE-discoverable — the same property that all six built-in DSLs already provide.

Step 5 — Cross-cutting (Stage 4). The most powerful step: the generator reads the built-in DDD model and adds permission metadata to every aggregate operation that carries a [HasPermission]. This is where the new DSL becomes inseparable from the existing ones:

public sealed class PermissionAuditEnricher : ICmfCrossCuttingStage
{
    public void Enrich(CmfCrossCuttingContext ctx)
    {
        foreach (var aggregate in ctx.Model.AllAggregates)
        foreach (var op in aggregate.Operations)
            if (op.HasAttribute<HasPermissionAttribute>(out var perm))
                ctx.AnnotateAuditEntry(op, "RequiredPermission", perm.Code);
    }
}

The result is that the audit log entries from Part 16 automatically gain a RequiredPermission field for every operation guarded by the new DSL — no change to the built-in audit interceptor. The CMF's permission matrix command (cmf report permissions) also picks up the new column on the next run.

Step 6 — Test fixtures. The generator emits a PermissionTestActor builder so tests can construct callers with the right permissions in one line:

[Fact]
public async Task Publishing_a_product_requires_the_publish_permission()
{
    var actor = PermissionTestActor.With(Permissions.Product_Create);   // missing publish
    var product = ProductTestBuilder.Valid().Build().Value;

    var result = product.Publish(_clock, actor);

    result.IsSuccess.Should().BeFalse();
    result.AsFailure().Code.Should().Be("PERM-301");
}

The whole DSL is around 800 lines of generator code. The consumer-visible surface is three attributes and one constant class.

Case Study 2: A [TenantScoped] DSL with Stronger Semantics

The CMF's built-in [TenantScoped] (described in Part 16) is intentionally minimal: it adds a TenantId column and filters reads. Some teams need more — for example, region + tenant compound scoping with hierarchical inheritance ("EU-DE inherits from EU which inherits from Global"). They write a second DSL on top of the built-in one:

[AttributeUsage(AttributeTargets.Class)]
public sealed class HierarchicalTenantAttribute : Attribute
{
    public string[] DimensionsInOrder { get; }
    public HierarchicalTenantAttribute(params string[] dimensions) {
        DimensionsInOrder = dimensions;
    }
}

Used like this:

[AggregateRoot("Order", BoundedContext = "Ordering")]
[TenantScoped]
[HierarchicalTenant("global", "region", "country", "tenantId")]
public partial class Order { /* ... */ }

The third-party generator hooks Stage 4 after the built-in tenant generator has run, then:

  1. Replaces the simple WHERE tenant_id = @t filter in OrderRepository.g.cs with a hierarchical filter that walks the dimensions (WHERE (global = 'global') AND (region = @region OR region IS NULL) AND ...).
  2. Adds a HierarchicalTenantContext interface to MyStore.Shared so the runtime resolves the active dimensions per request.
  3. Emits a database migration that converts the existing TenantId column into a tenant_path ltree column for efficient hierarchical lookups.
  4. Adds an analyzer HTEN101 that fails the build if a hierarchical-tenant aggregate is also marked [TenantBypass], on the theory that hierarchical scoping is too risky to bypass.

The interesting property is that the developer's M1 declaration only adds one attribute ([HierarchicalTenant(...)]). Everything else — the schema migration, the query rewriter, the analyzer, the runtime context interface — comes from the third-party DSL. Switching back to the built-in flat tenant model is a single attribute removal plus a cmf migrate to revert the schema.

Composing With the Built-In DSLs

The two case studies illustrate the two composition modes:

Mode Example Constraint
Additive [Permission] adds new files alongside existing ones The new generator only emits files; it never modifies the built-in outputs
Augmenting [HierarchicalTenant] rewrites the built-in repository file The new generator runs in a later stage than the built-in one and must explicitly opt-in via [CmfStageOrder(After = "Cmf.Tenant")]

The augmenting mode is more powerful and more dangerous. The CMF runtime checks at startup that no two generators have rewritten the same file in conflicting ways; a conflict raises an InvalidOperationException with both stage owners named, so the responsible team is identifiable.

Registration: How cmf Discovers Third-Party Generators

A consumer adds the package and the Cmf.Generators.Hosting MSBuild target picks it up automatically:

<ItemGroup>
  <PackageReference Include="Acme.Cmf.Permissions" Version="1.0.0" />
  <Analyzer Include="$(NuGetPackageRoot)acme.cmf.permissions/1.0.0/analyzers/dotnet/cs/Acme.Cmf.Permissions.Analyzers.dll" />
</ItemGroup>

The analyzers are surfaced via the standard Roslyn Analyzer MSBuild item; the generators are discovered via reflection over [CmfGenerator]-attributed types in any referenced assembly. cmf doctor lists every discovered generator in the toolchain panel:

$ cmf doctor
  ✓ .NET SDK 10.0.100
  ✓ Cmf.Lib 1.4.2 (built-in)
  ✓ Cmf.Generators (DDD, Content, Admin, Pages, Workflow, Requirements)
  ✓ Acme.Cmf.Permissions 1.0.0  (third-party, additive)
  ✓ Acme.Cmf.HierarchicalTenant 1.2.0  (third-party, augments Cmf.Tenant)

A failed package version constraint or a stage-order conflict shows up here as a red with a remediation hint.

When Not to Build a New DSL

Building a new DSL is reasonable when:

  • The concept is declarative (the developer wants to describe something, not write the logic).
  • The rule is enforceable at compile time (so the generator can prevent classes of mistakes).
  • The output is mechanical (no human judgement is needed to produce the generated artifacts).
  • The DSL has at least three distinct projects that would benefit (otherwise it's premature abstraction).

It is not reasonable when:

  • The "DSL" is really a single configuration value — use cmfconfig.json instead.
  • The logic is one-of-a-kind — write a hand-crafted service.
  • The generator would produce code the developer needs to read and modify — generated code is for the compiler to read, not humans.
  • The team has not built three projects worth of pain yet — the right number of DSLs in a codebase is the smallest number that explains the recurring patterns, not the largest.

The CMF ships with six because six is the smallest number that explains every CMS-style application the author has built. The seventh, eighth, and ninth DSL exist for the same reason but live in third-party packages instead of the framework, because their utility is project-specific. The M3 makes that boundary cheap to cross.

Versioning and Breaking Changes

Third-party DSL packages follow semver with one extra rule: a major version bump is required if the generated output changes shape in a way that an existing consumer's hand-written code would notice. Renaming Permissions.Product_Create to Permissions.ProductCreate is a major bump because consumers reference the constant by name. Adding a new parallel generated file is a minor bump because nothing existing breaks. The cmf doctor --check-versions command compares the installed major versions against the lockfile and warns if any consumer is on an outdated major.

Where the Boundary Is

The M3 was designed so that the same primitives that build the six in-tree DSLs also build the N out-of-tree DSLs. There is no privileged "core" code path; the built-in generators use the same ICmfDiscoveryStage interface a third-party generator does. This is the property that makes the CMF a framework in the deep sense: it can be extended along the same axis it was built on, without forking. The cost is that the M3 surface is deliberately small and stable — every new method on CmfDiscoveryContext is a versioning event for every plug-in in the ecosystem, so the surface evolves slowly. The benefit is that a five-year-old DSL package built against the M3 keeps working when the framework is upgraded.

⬇ Download