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

Modeling, Metamodeling, and Meta-Metamodeling: Building DSL Compilers in C#

Introduction

Model-Driven Engineering (MDE) is a software development methodology where models are first-class artifacts -- not documentation, not diagrams on a whiteboard, but the actual source of truth from which code, configurations, and behavior are derived.

At the heart of MDE lies a deceptively simple question: what is a model? And then, recursively: what defines the structure of a model? And finally: what defines the structure of structures?

These questions lead to three distinct layers of abstraction that the OMG (Object Management Group) formalized decades ago in the Meta-Object Facility (MOF) specification. This article explores these layers and demonstrates how to implement a complete meta-metamodeling framework in C# using attributes as the DSL surface and multi-staged Roslyn source generators as the compilation pipeline.

The goal: you define a DSL using C# attributes, and the compiler produces fully typed, validated, runtime-efficient code -- no reflection, no interpretation, no runtime overhead.

The Four Modeling Layers

The MOF architecture defines four abstraction layers, commonly labeled M0 through M3. Understanding these layers is essential before writing a single line of code.

M0 -- The Real World (Instances)

M0 is not code. It is the runtime state: the actual objects living in memory, the rows in a database, the messages on a wire.

// At runtime, M0 is what exists:
//   An Order object with Id=42, Customer="Acme Corp", Total=1500.00
//   A ProductLine with Sku="WIDGET-7", Quantity=30

M0 is what your program manipulates. You never define M0 -- it emerges from execution.

M1 -- The Model (Your Domain)

M1 is the model: the classes, relationships, and constraints that describe a specific domain. This is what most developers think of as "the code."

// M1: A domain model for an ordering system
public class Order
{
    public int Id { get; set; }
    public string Customer { get; set; }
    public List<OrderLine> Lines { get; set; }
    public decimal Total => Lines.Sum(l => l.Subtotal);
}

public class OrderLine
{
    public string Sku { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public decimal Subtotal => Quantity * UnitPrice;
}

An M1 model is an instance of an M2 metamodel. The Order class conforms to some structural rules -- it has properties, types, relationships. Those rules are defined at M2.

M2 -- The Metamodel (Your DSL)

M2 is the metamodel: the language that defines the structure and constraints of M1 models. A metamodel answers: what kinds of elements can exist in a model? What properties can they have? How can they relate to each other?

If M1 is "an Order has Lines," then M2 is "an Entity has Properties, and Properties have Types, and Entities can have Relationships to other Entities."

// M2: A metamodel for domain modeling
//   - Entity: a named structural element with properties
//   - Property: a named, typed slot on an entity
//   - Relationship: a directed association between entities
//   - Constraint: a validation rule on an entity or property

In traditional MDE tools (EMF, MetaEdit+, MPS), the metamodel is defined in a dedicated metamodeling language. In our approach, the metamodel is expressed through C# attributes.

M3 -- The Meta-Metamodel (The Framework)

M3 is the meta-metamodel: the language that defines the structure of metamodels themselves. It answers: what is an "Entity"? What is a "Property"? What structural primitives exist for building any metamodel?

M3 is self-describing -- it defines itself. The meta-metamodel is an instance of itself. This is not a paradox; it is a fixed point. Just as natural language can describe its own grammar, M3 defines the vocabulary for expressing any M2 metamodel.

In MOF, M3 defines concepts like:

  • Class -- a named, structured element
  • Attribute -- a typed data slot
  • Reference -- a directed association
  • DataType -- a primitive or complex value type
  • Package -- a namespace grouping
  • Constraint -- an invariant or validation rule
  • Enumeration -- a fixed set of named values

The key insight: M3 is the framework you ship. M2 is the DSL your users define. M1 is the code the DSL produces. M0 is what runs.

Diagram

Each layer is an instance of the layer above it. M1 models conform to M2 metamodels, which conform to M3. M3 conforms to itself.

Diagram

Why This Matters in Practice

Without the M3 layer, every DSL is a one-off. You build a source generator for entities, another for commands, another for events -- each with its own ad-hoc notion of "what a model element is." Consistency, tooling reuse, and composability are lost.

With a proper meta-metamodel:

  1. You define M3 once -- the structural primitives for building any metamodel
  2. DSL authors define M2 -- using M3 primitives, expressed as C# attributes
  3. Source generators compile M2 + M1 -- producing fully typed C# code
  4. End users write M1 -- using the DSL surface (attributes on their classes)
  5. The runtime executes M0 -- with zero reflection, zero interpretation

This is the architecture we will build.

C# Attributes as the DSL Surface

C# attributes are a natural fit for DSL surfaces because they are:

  • Declarative -- they describe what, not how
  • Analyzable at compile time -- Roslyn source generators can read them
  • Familiar to C# developers -- no new syntax to learn
  • Composable -- multiple attributes can be stacked on a single element
  • Typed -- attribute constructors and properties are type-checked by the compiler

M3: The Meta-Metamodel as Attributes

The meta-metamodel defines the primitive concepts available to metamodel authors. These are the building blocks:

// ============================================================
// M3: Meta-Metamodel Attributes
// These define what constructs are available for building DSLs
// ============================================================

/// <summary>
/// Marks an attribute class as a metamodel concept definition.
/// This is the M3 primitive that says "this attribute defines
/// a kind of model element."
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class MetaConceptAttribute : Attribute
{
    public string Name { get; }
    public string? Description { get; set; }
    public bool IsAbstract { get; set; }

    public MetaConceptAttribute(string name) => Name = name;
}

/// <summary>
/// Declares a typed property slot on a meta-concept.
/// When a metamodel author creates a concept, this defines
/// what configuration the concept accepts.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class MetaPropertyAttribute : Attribute
{
    public string Name { get; }
    public bool Required { get; set; }
    public object? DefaultValue { get; set; }

    public MetaPropertyAttribute(string name) => Name = name;
}

/// <summary>
/// Declares a reference (association) from one meta-concept to another.
/// This is how metamodel elements relate to each other.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class MetaReferenceAttribute : Attribute
{
    public string Name { get; }
    public string TargetConcept { get; }
    public Multiplicity Multiplicity { get; set; } = Multiplicity.One;
    public bool IsContainment { get; set; }
    public string? Opposite { get; set; }

    public MetaReferenceAttribute(string name, string targetConcept)
    {
        Name = name;
        TargetConcept = targetConcept;
    }
}

/// <summary>
/// Declares a constraint or invariant on a meta-concept.
/// Constraints are validated during model compilation.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class MetaConstraintAttribute : Attribute
{
    public string Name { get; }
    public string Expression { get; }
    public string? Message { get; set; }
    public Severity Severity { get; set; } = Severity.Error;

    public MetaConstraintAttribute(string name, string expression)
    {
        Name = name;
        Expression = expression;
    }
}

/// <summary>
/// Declares that a meta-concept inherits from another.
/// This enables metamodel-level inheritance hierarchies.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class MetaInheritsAttribute : Attribute
{
    public string BaseConcept { get; }
    public MetaInheritsAttribute(string baseConcept) => BaseConcept = baseConcept;
}

public enum Multiplicity { ZeroOrOne, One, ZeroOrMore, OneOrMore }
public enum Severity { Info, Warning, Error }

These M3 attributes are the fixed vocabulary. They never change. They are the axioms of the system.

Diagram

M2: Defining a DSL Using M3 Primitives

Now a DSL author uses M3 to define a metamodel. Let's build a simple Entity DSL -- a language for defining domain entities with typed properties, relationships, and validation rules.

// ============================================================
// M2: The Entity DSL Metamodel
// Defined using M3 primitives (MetaConcept, MetaProperty, etc.)
// ============================================================

/// <summary>
/// Defines a domain entity -- a named structural element
/// with properties and relationships.
/// </summary>
[MetaConcept("Entity")]
[MetaConstraint("MustHaveKey", "Properties.Any(p => p.IsKey)",
    Message = "Every entity must have at least one key property")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class EntityAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaProperty("Table")]
    public string? TableName { get; set; }

    [MetaProperty("Schema", DefaultValue = "dbo")]
    public string Schema { get; set; } = "dbo";

    [MetaProperty("IsAbstract")]
    public bool IsAbstract { get; set; }

    public EntityAttribute(string name) => Name = name;
}

/// <summary>
/// Defines a typed property on an entity.
/// </summary>
[MetaConcept("Property")]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class PropertyAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaProperty("Type", Required = true)]
    public string TypeName { get; }

    [MetaProperty("IsKey")]
    public bool IsKey { get; set; }

    [MetaProperty("Required")]
    public bool Required { get; set; }

    [MetaProperty("MaxLength")]
    public int? MaxLength { get; set; }

    [MetaProperty("DefaultValue")]
    public string? DefaultValue { get; set; }

    public PropertyAttribute(string name, string typeName)
    {
        Name = name;
        TypeName = typeName;
    }
}

/// <summary>
/// Defines a relationship between two entities.
/// </summary>
[MetaConcept("Relationship")]
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class RelationshipAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaReference("Target", "Entity", Multiplicity = Multiplicity.One)]
    public string TargetEntity { get; }

    [MetaProperty("Multiplicity")]
    public Multiplicity Multiplicity { get; set; } = Multiplicity.One;

    [MetaProperty("CascadeDelete")]
    public bool CascadeDelete { get; set; }

    [MetaReference("Inverse", "Relationship")]
    public string? InverseProperty { get; set; }

    public RelationshipAttribute(string name, string targetEntity)
    {
        Name = name;
        TargetEntity = targetEntity;
    }
}

/// <summary>
/// Defines a computed property derived from other properties.
/// </summary>
[MetaConcept("ComputedProperty")]
[MetaInherits("Property")]
[AttributeUsage(AttributeTargets.Property)]
public sealed class ComputedAttribute : Attribute
{
    [MetaProperty("Expression", Required = true)]
    public string Expression { get; }

    public ComputedAttribute(string expression) => Expression = expression;
}

/// <summary>
/// Defines a validation constraint on an entity or property.
/// </summary>
[MetaConcept("Validation")]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = true)]
public sealed class ValidateAttribute : Attribute
{
    [MetaProperty("Rule", Required = true)]
    public string Rule { get; }

    [MetaProperty("Message")]
    public string? Message { get; set; }

    [MetaProperty("Severity")]
    public Severity Severity { get; set; } = Severity.Error;

    public ValidateAttribute(string rule) => Rule = rule;
}

This is the DSL definition. Notice how every attribute is annotated with M3 primitives ([MetaConcept], [MetaProperty], [MetaReference]). The M3 source generator can read these to understand the structure of the DSL and validate that it is well-formed.

Diagram

M1: Using the DSL

End users (domain developers) write M1 models using the DSL surface:

// ============================================================
// M1: Domain Model written using the Entity DSL
// ============================================================

[Entity("Customer", TableName = "Customers")]
[Validate("Name.Length > 0", Message = "Customer name must not be empty")]
public partial class Customer
{
    [Property("Id", "int", IsKey = true)]
    public partial int Id { get; }

    [Property("Name", "string", Required = true, MaxLength = 200)]
    public partial string Name { get; }

    [Property("Email", "string", MaxLength = 320)]
    [Validate("value.Contains('@')", Message = "Must be a valid email")]
    public partial string Email { get; }

    [Property("CreditLimit", "decimal", DefaultValue = "0")]
    public partial decimal CreditLimit { get; }

    [Relationship("Orders", "Order", Multiplicity = Multiplicity.ZeroOrMore,
                  InverseProperty = "Customer")]
    public partial IReadOnlyList<Order> Orders { get; }
}

[Entity("Order", TableName = "Orders")]
public partial class Order
{
    [Property("Id", "int", IsKey = true)]
    public partial int Id { get; }

    [Property("OrderDate", "DateTime", Required = true)]
    public partial DateTime OrderDate { get; }

    [Relationship("Customer", "Customer", CascadeDelete = false)]
    public partial Customer Customer { get; }

    [Relationship("Lines", "OrderLine", Multiplicity = Multiplicity.OneOrMore,
                  CascadeDelete = true)]
    public partial IReadOnlyList<OrderLine> Lines { get; }

    [Computed("Lines.Sum(l => l.Subtotal)")]
    public partial decimal Total { get; }
}

[Entity("OrderLine", TableName = "OrderLines")]
public partial class OrderLine
{
    [Property("Id", "int", IsKey = true)]
    public partial int Id { get; }

    [Property("Sku", "string", Required = true, MaxLength = 50)]
    public partial string Sku { get; }

    [Property("Quantity", "int", Required = true)]
    [Validate("value > 0", Message = "Quantity must be positive")]
    public partial int Quantity { get; }

    [Property("UnitPrice", "decimal", Required = true)]
    [Validate("value >= 0", Message = "Price cannot be negative")]
    public partial decimal UnitPrice { get; }

    [Computed("Quantity * UnitPrice")]
    public partial decimal Subtotal { get; }
}

This is pure declarative C#. No implementation code. The source generators will compile this into full implementations.

The domain model expressed by these declarations forms this entity-relationship graph:

Diagram

Multi-Staged Source Generators

The compilation pipeline is where the architecture becomes powerful -- but it requires understanding a critical constraint of the Roslyn source generator model.

The Roslyn Constraint: Generators Cannot See Each Other's Output

Within a single compilation (a single .csproj), all source generators run in parallel against the original source code. Generator A cannot read code emitted by Generator B. They all see the same Compilation object, which only contains the hand-written source files and referenced assemblies -- not the output of other generators in the same project.

This means the naive approach of "Stage 0 emits code, Stage 1 reads it" does not work inside a single project.

There are two real strategies to achieve multi-staged generation:

Strategy 1: Multi-Project Pipeline (True Staging)

The cleanest approach is to split stages across separate projects in a solution. Each stage is a source generator shipped as a NuGet package or analyzer assembly, and each project in the chain references the previous one. Because project references are compiled assemblies, the next stage's generator can see the previous stage's output as compiled types.

Diagram

The solution structure looks like this:

MySolution.sln
│
├── src/
│   ├── MyDsl.Meta/                          ← M3 layer
│   │   ├── MyDsl.Meta.csproj
│   │   ├── Attributes/
│   │   │   ├── MetaConceptAttribute.cs
│   │   │   ├── MetaPropertyAttribute.cs
│   │   │   ├── MetaReferenceAttribute.cs
│   │   │   └── MetaConstraintAttribute.cs
│   │   └── Enums/
│   │       ├── Multiplicity.cs
│   │       └── Severity.cs
│   │
│   ├── MyDsl.Meta.Generators/               ← Stage 0 generator (analyzer)
│   │   ├── MyDsl.Meta.Generators.csproj
│   │   └── MetaMetamodelGenerator.cs
│   │
│   ├── MyDsl.EntityDsl/                     ← M2 layer (references Meta)
│   │   ├── MyDsl.EntityDsl.csproj
│   │   ├── EntityAttribute.cs
│   │   ├── PropertyAttribute.cs
│   │   ├── RelationshipAttribute.cs
│   │   └── ValidateAttribute.cs
│   │
│   ├── MyDsl.EntityDsl.Generators/          ← Stage 1+2 generators (analyzer)
│   │   ├── MyDsl.EntityDsl.Generators.csproj
│   │   ├── MetamodelValidatorGenerator.cs
│   │   └── EntityCodeGenerator.cs
│   │
│   └── MyApp.Domain/                        ← M1 layer (references EntityDsl)
│       ├── MyApp.Domain.csproj
│       ├── Customer.cs
│       ├── Order.cs
│       └── OrderLine.cs

The .csproj files wire the generators as analyzers:

<!-- MyDsl.EntityDsl.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>

  <!-- Reference M3 as a normal dependency -->
  <ItemGroup>
    <ProjectReference Include="..\MyDsl.Meta\MyDsl.Meta.csproj" />
  </ItemGroup>

  <!-- Stage 0 generator runs when THIS project compiles -->
  <ItemGroup>
    <ProjectReference Include="..\MyDsl.Meta.Generators\MyDsl.Meta.Generators.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>
<!-- MyApp.Domain.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
  </PropertyGroup>

  <!-- Reference M2 DSL (which transitively brings M3) -->
  <ItemGroup>
    <ProjectReference Include="..\MyDsl.EntityDsl\MyDsl.EntityDsl.csproj" />
  </ItemGroup>

  <!-- Stage 1+2 generators run when THIS project compiles -->
  <ItemGroup>
    <ProjectReference Include="..\MyDsl.EntityDsl.Generators\MyDsl.EntityDsl.Generators.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>

When dotnet build runs:

  1. MyDsl.Meta compiles first (no generators, just attribute classes)
  2. MyDsl.Meta.Generators compiles (the Stage 0 generator assembly)
  3. MyDsl.EntityDsl compiles -- Stage 0 runs here, reading [MetaConcept] from the M3 assembly and emitting MetamodelRegistry.g.cs into this project's compilation
  4. MyDsl.EntityDsl.Generators compiles (the Stage 1+2 generator assembly)
  5. MyApp.Domain compiles -- Stage 1+2 runs here, reading [Entity] from hand-written classes and MetamodelRegistry from the compiled EntityDsl assembly

Each stage sees the compiled output of the previous stage as a referenced assembly. True staging.

Strategy 2: Single Generator with Internal Pipeline (Intra-Generator Staging)

When you don't want the complexity of multiple projects, you can implement all stages inside a single IIncrementalGenerator by chaining the incremental pipeline operators. The "stages" become transformations within one generator:

Diagram

In code, this looks like:

[Generator]
public sealed class UnifiedDslGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // ── Stage 0: Extract metamodel concept definitions ──
        // Read all [MetaConcept]-annotated attribute classes
        var conceptDefinitions = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyDsl.Meta.MetaConceptAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => ExtractConceptDefinition(ctx, ct))
            .Where(static c => c is not null)
            .Select(static (c, _) => c!);

        // Collect all concepts into a registry (single value)
        var registry = conceptDefinitions
            .Collect()
            .Select(static (concepts, _) => BuildRegistry(concepts));

        // ── Stage 1+2: Extract and validate model elements ──
        // Read all classes annotated with DSL attributes (e.g., [Entity])
        var entityDeclarations = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyDsl.EntityAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => ExtractRawEntity(ctx, ct))
            .Where(static e => e is not null)
            .Select(static (e, _) => e!);

        // Combine each entity with the full registry for validation
        // This is the key: Combine() creates a data dependency
        // that acts like a stage boundary
        var validatedEntities = entityDeclarations
            .Combine(registry)
            .Select(static (pair, ct) =>
            {
                var (entity, reg) = pair;

                // Stage 1: validate against metamodel
                var violations = ValidateAgainstMetamodel(entity, reg);
                if (violations.Count > 0)
                    return (Entity: entity, Violations: violations, IsValid: false);

                // Stage 2: enrich with resolved types, defaults, etc.
                var enriched = EnrichEntity(entity, reg);
                return (Entity: enriched, Violations: violations, IsValid: true);
            });

        // ── Emit: report diagnostics and generate code ──
        context.RegisterSourceOutput(validatedEntities, static (spc, result) =>
        {
            // Report validation errors as compiler diagnostics
            foreach (var v in result.Violations)
            {
                spc.ReportDiagnostic(Diagnostic.Create(
                    new DiagnosticDescriptor(
                        v.Code, v.Title, v.Message, "DslValidation",
                        DiagnosticSeverity.Error, true),
                    v.Location));
            }

            if (!result.IsValid)
                return;

            // Stage 2 output: generate implementation code
            EmitEntityImplementation(spc, result.Entity);
            EmitEntityBuilder(spc, result.Entity);
            EmitEntityValidator(spc, result.Entity);

            // Stage 3: cross-cutting concerns
            EmitJsonSerialization(spc, result.Entity);
            EmitMappingExtensions(spc, result.Entity);
        });
    }
}

The critical operator is Combine(). It creates a data dependency: each entity declaration is paired with the collected registry, so the validation/enrichment logic has access to the full metamodel. The incremental pipeline ensures this is cached -- if only one entity changes, only that entity is re-validated and re-generated.

Diagram

Which Strategy When?

Concern Multi-Project Single Generator
True stage isolation Each stage sees compiled types from the previous Stages share in-memory data structures
Complexity More .csproj files, solution structure One generator, more complex pipeline code
Reusability M3 and M2 are independent NuGet packages Everything is bundled together
Build performance Parallel project builds, but more build steps Single generator invocation, but one project
Suitable for Framework-level DSLs shipped to other teams Application-level DSLs within one solution

For a meta-metamodeling framework (M3) that other teams consume to build their own DSLs (M2), the multi-project approach is the right choice. The M3 package is stable, versioned, and consumed as a dependency. For an application-specific DSL where M2 and M1 live in the same solution, the single-generator approach avoids unnecessary project sprawl.

The Pipeline Architecture

Diagram
Stage 0: M3 Meta-Metamodel Generator
   Reads: [MetaConcept], [MetaProperty], [MetaReference] on attribute classes
   Produces: Metamodel registry, DSL validation rules, metamodel type system

Stage 1: M2 Metamodel Validator
   Reads: Stage 0 output + M1 model classes with DSL attributes
   Produces: Validated model graph, diagnostics for invalid models

Stage 2: M1 Code Generator
   Reads: Stage 1 validated model graph
   Produces: Implementation code (properties, builders, validation, repositories)

Stage 3: Cross-Cutting Concerns Generator
   Reads: Stage 2 output
   Produces: Serialization, mapping, API endpoints, migration scripts

Stage 0: The Meta-Metamodel Generator

This generator reads the M3 annotations on attribute classes and builds a registry of all metamodel concepts:

[Generator]
public sealed class MetaMetamodelGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Find all classes annotated with [MetaConcept]
        var concepts = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyDsl.Meta.MetaConceptAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => ExtractConceptDefinition(ctx, ct))
            .Where(static c => c is not null)
            .Select(static (c, _) => c!);

        // Collect all concepts into a single model
        var metamodel = concepts.Collect();

        // Generate the metamodel registry
        context.RegisterSourceOutput(metamodel, static (spc, concepts) =>
        {
            var registry = BuildMetamodelRegistry(concepts);
            ValidateMetamodel(spc, registry);
            EmitMetamodelRegistry(spc, registry);
        });
    }

    private static ConceptDefinition? ExtractConceptDefinition(
        GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
    {
        var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
        var metaConcept = ctx.Attributes
            .First(a => a.AttributeClass?.Name == "MetaConceptAttribute");

        var name = (string)metaConcept.ConstructorArguments[0].Value!;
        var isAbstract = metaConcept.NamedArguments
            .FirstOrDefault(a => a.Key == "IsAbstract").Value.Value is true;

        // Extract MetaProperty definitions from the attribute's own properties
        var properties = new List<PropertyDefinition>();
        var references = new List<ReferenceDefinition>();

        foreach (var member in symbol.GetMembers().OfType<IPropertySymbol>())
        {
            foreach (var attr in member.GetAttributes())
            {
                if (attr.AttributeClass?.Name == "MetaPropertyAttribute")
                {
                    properties.Add(new PropertyDefinition(
                        Name: (string)attr.ConstructorArguments[0].Value!,
                        Type: member.Type.ToDisplayString(),
                        Required: attr.NamedArguments
                            .FirstOrDefault(a => a.Key == "Required")
                            .Value.Value is true,
                        DefaultValue: attr.NamedArguments
                            .FirstOrDefault(a => a.Key == "DefaultValue")
                            .Value.Value));
                }
                else if (attr.AttributeClass?.Name == "MetaReferenceAttribute")
                {
                    references.Add(new ReferenceDefinition(
                        Name: (string)attr.ConstructorArguments[0].Value!,
                        TargetConcept: (string)attr.ConstructorArguments[1].Value!,
                        Multiplicity: (Multiplicity)(attr.NamedArguments
                            .FirstOrDefault(a => a.Key == "Multiplicity")
                            .Value.Value ?? Multiplicity.One),
                        IsContainment: attr.NamedArguments
                            .FirstOrDefault(a => a.Key == "IsContainment")
                            .Value.Value is true));
                }
            }
        }

        // Extract constraints
        var constraints = symbol.GetAttributes()
            .Where(a => a.AttributeClass?.Name == "MetaConstraintAttribute")
            .Select(a => new ConstraintDefinition(
                Name: (string)a.ConstructorArguments[0].Value!,
                Expression: (string)a.ConstructorArguments[1].Value!,
                Message: a.NamedArguments
                    .FirstOrDefault(x => x.Key == "Message").Value.Value as string))
            .ToList();

        // Extract inheritance
        var inherits = symbol.GetAttributes()
            .FirstOrDefault(a => a.AttributeClass?.Name == "MetaInheritsAttribute");
        var baseConcept = inherits?.ConstructorArguments[0].Value as string;

        return new ConceptDefinition(
            name, isAbstract, baseConcept, properties, references, constraints,
            symbol.ToDisplayString());
    }

    private static void EmitMetamodelRegistry(
        SourceProductionContext spc, MetamodelRegistry registry)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine("namespace MyDsl.Meta.Generated;");
        sb.AppendLine();
        sb.AppendLine("public static class MetamodelRegistry");
        sb.AppendLine("{");
        sb.AppendLine("    public static IReadOnlyDictionary<string, ConceptInfo> Concepts { get; }");
        sb.AppendLine();
        sb.AppendLine("    static MetamodelRegistry()");
        sb.AppendLine("    {");
        sb.AppendLine("        var concepts = new Dictionary<string, ConceptInfo>();");

        foreach (var concept in registry.Concepts)
        {
            sb.AppendLine($"        concepts[\"{concept.Name}\"] = new ConceptInfo(");
            sb.AppendLine($"            Name: \"{concept.Name}\",");
            sb.AppendLine($"            IsAbstract: {concept.IsAbstract.ToString().ToLower()},");
            sb.AppendLine($"            BaseConcept: {(concept.BaseConcept != null ? $"\"{concept.BaseConcept}\"" : "null")},");
            sb.AppendLine($"            Properties: new[]");
            sb.AppendLine($"            {{");
            foreach (var prop in concept.Properties)
            {
                sb.AppendLine($"                new PropertyInfo(\"{prop.Name}\", \"{prop.Type}\", {prop.Required.ToString().ToLower()}, {(prop.DefaultValue != null ? $"\"{prop.DefaultValue}\"" : "null")}),");
            }
            sb.AppendLine($"            }},");
            sb.AppendLine($"            References: new[]");
            sb.AppendLine($"            {{");
            foreach (var reference in concept.References)
            {
                sb.AppendLine($"                new ReferenceInfo(\"{reference.Name}\", \"{reference.TargetConcept}\", Multiplicity.{reference.Multiplicity}, {reference.IsContainment.ToString().ToLower()}),");
            }
            sb.AppendLine($"            }});");
        }

        sb.AppendLine("        Concepts = concepts;");
        sb.AppendLine("    }");
        sb.AppendLine("}");

        spc.AddSource("MetamodelRegistry.g.cs", sb.ToString());
    }
}

Stage 1: The Metamodel Validator

This generator reads the M1 model classes, validates them against the M2 metamodel definitions, and produces a validated model graph:

[Generator]
public sealed class MetamodelValidatorGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Find all classes annotated with DSL attributes (e.g., [Entity])
        var modelElements = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (node, _) => node is ClassDeclarationSyntax cds
                    && cds.AttributeLists.Count > 0,
                transform: static (ctx, ct) => ExtractModelElement(ctx, ct))
            .Where(static e => e is not null)
            .Select(static (e, _) => e!);

        var allElements = modelElements.Collect();

        context.RegisterSourceOutput(allElements, static (spc, elements) =>
        {
            var graph = BuildModelGraph(elements);

            // Validate: all required properties are set
            foreach (var element in graph.Elements)
            {
                foreach (var violation in ValidateElement(element, graph))
                {
                    spc.ReportDiagnostic(Diagnostic.Create(
                        new DiagnosticDescriptor(
                            violation.Code,
                            violation.Title,
                            violation.Message,
                            "DslValidation",
                            violation.Severity == Severity.Error
                                ? DiagnosticSeverity.Error
                                : DiagnosticSeverity.Warning,
                            isEnabledByDefault: true),
                        violation.Location));
                }
            }

            // Emit the validated model graph as a source file
            // that subsequent generators can consume
            EmitModelGraph(spc, graph);
        });
    }

    private static IEnumerable<Violation> ValidateElement(
        ModelElement element, ModelGraph graph)
    {
        // Check required meta-properties are present
        foreach (var requiredProp in element.Concept.Properties.Where(p => p.Required))
        {
            if (!element.HasValue(requiredProp.Name))
            {
                yield return new Violation(
                    Code: "DSL001",
                    Title: "Missing required property",
                    Message: $"'{element.Name}' is missing required property " +
                             $"'{requiredProp.Name}' from concept '{element.Concept.Name}'",
                    Severity: Severity.Error,
                    Location: element.Location);
            }
        }

        // Check reference targets exist
        foreach (var reference in element.Concept.References)
        {
            var targetName = element.GetReferenceTarget(reference.Name);
            if (targetName != null && !graph.HasElement(reference.TargetConcept, targetName))
            {
                yield return new Violation(
                    Code: "DSL002",
                    Title: "Invalid reference target",
                    Message: $"'{element.Name}' references '{targetName}' " +
                             $"but no {reference.TargetConcept} with that name exists",
                    Severity: Severity.Error,
                    Location: element.Location);
            }
        }

        // Check multiplicity constraints
        foreach (var reference in element.Concept.References)
        {
            var count = element.GetReferenceCount(reference.Name);
            if (reference.Multiplicity == Multiplicity.OneOrMore && count == 0)
            {
                yield return new Violation(
                    Code: "DSL003",
                    Title: "Multiplicity violation",
                    Message: $"'{element.Name}.{reference.Name}' requires at least one element",
                    Severity: Severity.Error,
                    Location: element.Location);
            }
        }

        // Evaluate metamodel constraints
        foreach (var constraint in element.Concept.Constraints)
        {
            if (!EvaluateConstraint(constraint, element, graph))
            {
                yield return new Violation(
                    Code: "DSL004",
                    Title: constraint.Name,
                    Message: constraint.Message
                        ?? $"Constraint '{constraint.Name}' violated on '{element.Name}'",
                    Severity: constraint.Severity,
                    Location: element.Location);
            }
        }
    }
}

The key here is that validation errors become compiler errors. A malformed model does not compile. This is the power of compile-time DSLs -- invalid models are rejected before any code runs.

Stage 2: The Code Generator

With a validated model graph, the code generator produces implementation code:

[Generator]
public sealed class EntityCodeGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var entities = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyDsl.EntityAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => ExtractEntity(ctx, ct))
            .Where(static e => e is not null)
            .Select(static (e, _) => e!);

        context.RegisterSourceOutput(entities, static (spc, entity) =>
        {
            EmitEntityImplementation(spc, entity);
            EmitEntityBuilder(spc, entity);
            EmitEntityValidator(spc, entity);
        });
    }

    private static void EmitEntityImplementation(
        SourceProductionContext spc, EntityModel entity)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine($"namespace {entity.Namespace};");
        sb.AppendLine();
        sb.AppendLine($"public partial class {entity.ClassName}");
        sb.AppendLine("{");

        // Backing fields for properties
        foreach (var prop in entity.Properties)
        {
            sb.AppendLine($"    private {prop.ClrType} _{prop.Name.ToCamelCase()};");
        }
        sb.AppendLine();

        // Property implementations
        foreach (var prop in entity.Properties)
        {
            var fieldName = $"_{prop.Name.ToCamelCase()}";
            sb.AppendLine($"    public partial {prop.ClrType} {prop.Name}");
            sb.AppendLine($"    {{");
            sb.AppendLine($"        get => {fieldName};");
            sb.AppendLine($"    }}");
            sb.AppendLine();
        }

        // Computed properties
        foreach (var comp in entity.ComputedProperties)
        {
            sb.AppendLine($"    public partial {comp.ClrType} {comp.Name}");
            sb.AppendLine($"    {{");
            sb.AppendLine($"        get => {comp.Expression};");
            sb.AppendLine($"    }}");
            sb.AppendLine();
        }

        // Relationship navigation properties
        foreach (var rel in entity.Relationships)
        {
            if (rel.Multiplicity is Multiplicity.ZeroOrMore or Multiplicity.OneOrMore)
            {
                sb.AppendLine($"    private readonly List<{rel.TargetType}> _{rel.Name.ToCamelCase()}List = new();");
                sb.AppendLine($"    public partial IReadOnlyList<{rel.TargetType}> {rel.Name}");
                sb.AppendLine($"    {{");
                sb.AppendLine($"        get => _{rel.Name.ToCamelCase()}List.AsReadOnly();");
                sb.AppendLine($"    }}");
            }
            else
            {
                sb.AppendLine($"    private {rel.TargetType}{"?"} _{rel.Name.ToCamelCase()};");
                sb.AppendLine($"    public partial {rel.TargetType} {rel.Name}");
                sb.AppendLine($"    {{");
                sb.AppendLine($"        get => _{rel.Name.ToCamelCase()};");
                sb.AppendLine($"    }}");
            }
            sb.AppendLine();
        }

        // Equality based on key properties
        var keys = entity.Properties.Where(p => p.IsKey).ToList();
        if (keys.Count > 0)
        {
            sb.AppendLine($"    public override bool Equals(object? obj) =>");
            sb.AppendLine($"        obj is {entity.ClassName} other");
            foreach (var key in keys)
            {
                sb.AppendLine($"        && {key.Name} == other.{key.Name}");
            }
            sb.AppendLine($"        ;");
            sb.AppendLine();

            sb.AppendLine($"    public override int GetHashCode() =>");
            sb.AppendLine($"        HashCode.Combine({string.Join(", ", keys.Select(k => k.Name))});");
        }

        sb.AppendLine("}");
        spc.AddSource($"{entity.ClassName}.Implementation.g.cs", sb.ToString());
    }

    private static void EmitEntityBuilder(
        SourceProductionContext spc, EntityModel entity)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine($"namespace {entity.Namespace};");
        sb.AppendLine();
        sb.AppendLine($"public partial class {entity.ClassName}");
        sb.AppendLine("{");
        sb.AppendLine($"    public sealed class Builder");
        sb.AppendLine($"    {{");
        sb.AppendLine($"        private readonly {entity.ClassName} _instance = new();");
        sb.AppendLine();

        foreach (var prop in entity.Properties)
        {
            sb.AppendLine($"        public Builder With{prop.Name}({prop.ClrType} value)");
            sb.AppendLine($"        {{");
            sb.AppendLine($"            _instance._{prop.Name.ToCamelCase()} = value;");
            sb.AppendLine($"            return this;");
            sb.AppendLine($"        }}");
            sb.AppendLine();
        }

        foreach (var rel in entity.Relationships
            .Where(r => r.Multiplicity is Multiplicity.ZeroOrMore or Multiplicity.OneOrMore))
        {
            sb.AppendLine($"        public Builder Add{rel.Name.TrimEnd('s')}({rel.TargetType} value)");
            sb.AppendLine($"        {{");
            sb.AppendLine($"            _instance._{rel.Name.ToCamelCase()}List.Add(value);");
            sb.AppendLine($"            return this;");
            sb.AppendLine($"        }}");
            sb.AppendLine();
        }

        sb.AppendLine($"        public Result<{entity.ClassName}, ValidationException> Build()");
        sb.AppendLine($"        {{");
        sb.AppendLine($"            var errors = {entity.ClassName}.Validate(_instance);");
        sb.AppendLine($"            if (errors.Count > 0)");
        sb.AppendLine($"                return Result<{entity.ClassName}, ValidationException>.Failure(new ValidationException(errors));");
        sb.AppendLine($"            return Result<{entity.ClassName}, ValidationException>.Success(_instance);");
        sb.AppendLine($"        }}");

        sb.AppendLine($"    }}");
        sb.AppendLine("}");
        spc.AddSource($"{entity.ClassName}.Builder.g.cs", sb.ToString());
    }

    private static void EmitEntityValidator(
        SourceProductionContext spc, EntityModel entity)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine($"namespace {entity.Namespace};");
        sb.AppendLine();
        sb.AppendLine($"public partial class {entity.ClassName}");
        sb.AppendLine("{");
        sb.AppendLine($"    public static IReadOnlyList<string> Validate({entity.ClassName} instance)");
        sb.AppendLine($"    {{");
        sb.AppendLine($"        var errors = new List<string>();");

        foreach (var prop in entity.Properties.Where(p => p.Required))
        {
            if (prop.ClrType == "string")
            {
                sb.AppendLine($"        if (string.IsNullOrWhiteSpace(instance.{prop.Name}))");
                sb.AppendLine($"            errors.Add(\"{prop.Name} is required.\");");
            }
            else if (!prop.IsValueType)
            {
                sb.AppendLine($"        if (instance.{prop.Name} is null)");
                sb.AppendLine($"            errors.Add(\"{prop.Name} is required.\");");
            }
        }

        foreach (var prop in entity.Properties.Where(p => p.MaxLength.HasValue))
        {
            sb.AppendLine($"        if (instance.{prop.Name}?.Length > {prop.MaxLength})");
            sb.AppendLine($"            errors.Add(\"{prop.Name} must not exceed {prop.MaxLength} characters.\");");
        }

        foreach (var validation in entity.Validations)
        {
            sb.AppendLine($"        // {validation.Message ?? validation.Rule}");
            sb.AppendLine($"        if (!({TranslateValidationExpression(validation.Rule, "instance")}))");
            sb.AppendLine($"            errors.Add(\"{EscapeString(validation.Message ?? $"Validation failed: {validation.Rule}")}\");");
        }

        sb.AppendLine($"        return errors;");
        sb.AppendLine($"    }}");
        sb.AppendLine("}");
        spc.AddSource($"{entity.ClassName}.Validation.g.cs", sb.ToString());
    }
}

Stage 3: Cross-Cutting Generators

Additional generators can consume the same model graph to produce serialization, mapping, API endpoints, or database migration code:

[Generator]
public sealed class EntitySerializationGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var entities = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyDsl.EntityAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => ExtractEntity(ctx, ct))
            .Where(static e => e is not null);

        context.RegisterSourceOutput(entities, static (spc, entity) =>
        {
            // Emit System.Text.Json source-generated serialization
            EmitJsonContext(spc, entity);

            // Emit mapping extensions (Entity <-> DTO)
            EmitMappingExtensions(spc, entity);
        });
    }
}

The Compilation Flow

When you build a project using this framework, here is what happens:

┌──────────────────────────────────────────────────────────────────┐
│  Source Code (what you write)                                    │
│                                                                  │
│  [Entity("Customer")]                                            │
│  public partial class Customer                                   │
│  {                                                               │
│      [Property("Name", "string", Required = true)]               │
│      public partial string Name { get; }                         │
│  }                                                               │
└──────────────────────┬───────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────┐
│  Stage 0: Meta-Metamodel Generator                               │
│  Reads [MetaConcept] on EntityAttribute                          │
│  Produces: MetamodelRegistry.g.cs                                │
│  ── "Entity has properties: Name, Table, Schema, IsAbstract"     │
│  ── "Property has properties: Name, Type, IsKey, Required..."    │
└──────────────────────┬───────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────┐
│  Stage 1: Metamodel Validator                                    │
│  Reads Customer class + MetamodelRegistry                        │
│  Validates: Entity has at least one key property? ✓              │
│  Validates: All required meta-properties set? ✓                  │
│  Validates: Reference targets exist? ✓                           │
│  Produces: ModelGraph.g.cs (validated model)                     │
│  Reports: Compiler errors/warnings for violations                │
└──────────────────────┬───────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────┐
│  Stage 2: Code Generator                                         │
│  Reads validated model graph                                     │
│  Produces:                                                       │
│   ── Customer.Implementation.g.cs (backing fields, accessors)    │
│   ── Customer.Builder.g.cs (fluent builder)                      │
│   ── Customer.Validation.g.cs (runtime validation)               │
└──────────────────────┬───────────────────────────────────────────┘
                       │
                       ▼
┌──────────────────────────────────────────────────────────────────┐
│  Stage 3: Cross-Cutting Generators                               │
│   ── Customer.Json.g.cs (serialization)                          │
│   ── Customer.Mapping.g.cs (DTO mapping)                         │
│   ── Customer.Repository.g.cs (data access)                      │
│   ── Migrations/202603_CreateCustomers.g.cs (schema)             │
└──────────────────────────────────────────────────────────────────┘

Composing Multiple DSLs

Because M3 is a shared foundation, multiple DSLs can coexist and interoperate. You can define:

  • An Entity DSL for domain objects (as shown above)
  • A Command DSL for CQRS command definitions
  • An Event DSL for domain events
  • A Saga DSL for process orchestration
  • A API DSL for REST/gRPC endpoint definitions

Each DSL is a set of M2 attributes built on M3 primitives. Their source generators can share model graph data and cross-reference each other:

Diagram
// Command DSL (M2)
[MetaConcept("Command")]
[MetaConstraint("HasHandler", "HandlerMethod != null",
    Message = "Every command must have a handler")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class CommandAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaReference("Entity", "Entity")]
    public string? TargetEntity { get; set; }

    [MetaProperty("ReturnsResult")]
    public bool ReturnsResult { get; set; }

    public CommandAttribute(string name) => Name = name;
}

// Usage (M1)
[Command("CreateCustomer", TargetEntity = "Customer", ReturnsResult = true)]
public partial class CreateCustomerCommand
{
    [Property("Name", "string", Required = true)]
    public partial string Name { get; }

    [Property("Email", "string")]
    public partial string Email { get; }

    [Property("CreditLimit", "decimal", DefaultValue = "0")]
    public partial decimal CreditLimit { get; }
}

// Event DSL (M2)
[MetaConcept("DomainEvent")]
[AttributeUsage(AttributeTargets.Class)]
public sealed class DomainEventAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }

    [MetaReference("SourceEntity", "Entity", Multiplicity = Multiplicity.One)]
    public string SourceEntity { get; }

    public DomainEventAttribute(string name, string sourceEntity)
    {
        Name = name;
        SourceEntity = sourceEntity;
    }
}

// Usage (M1)
[DomainEvent("CustomerCreated", "Customer")]
public partial class CustomerCreatedEvent
{
    [Property("CustomerId", "int", Required = true)]
    public partial int CustomerId { get; }

    [Property("Name", "string", Required = true)]
    public partial string Name { get; }

    [Property("Timestamp", "DateTimeOffset", Required = true)]
    public partial DateTimeOffset Timestamp { get; }
}

The cross-cutting generator can see that CreateCustomerCommand targets the Customer entity and that CustomerCreatedEvent originates from Customer, enabling automatic wiring:

// Auto-generated handler scaffold
public partial class CreateCustomerCommandHandler
    : ICommandHandler<CreateCustomerCommand, Result<Customer>>
{
    public async Task<Result<Customer>> HandleAsync(
        CreateCustomerCommand command, CancellationToken ct)
    {
        var customer = new Customer.Builder()
            .WithName(command.Name)
            .WithEmail(command.Email)
            .WithCreditLimit(command.CreditLimit)
            .Build();

        // Generated: persist and publish event
        await _repository.AddAsync(customer, ct);
        await _eventBus.PublishAsync(new CustomerCreatedEvent.Builder()
            .WithCustomerId(customer.Id)
            .WithName(customer.Name)
            .WithTimestamp(DateTimeOffset.UtcNow)
            .Build(), ct);

        return Result<Customer>.Success(customer);
    }
}

Incremental Generation and Performance

Roslyn's IIncrementalGenerator API is designed for performance. Each stage should:

  1. Filter early -- use ForAttributeWithMetadataName to avoid scanning the entire syntax tree
  2. Cache aggressively -- use WithComparer to avoid regeneration when inputs haven't changed
  3. Emit minimal output -- only regenerate files whose inputs have changed
// Good: filter precisely, transform early, compare by value
var entities = context.SyntaxProvider
    .ForAttributeWithMetadataName(
        "MyDsl.EntityAttribute",
        predicate: static (node, _) => node is ClassDeclarationSyntax,
        transform: static (ctx, ct) => ExtractEntity(ctx, ct))
    .Where(static e => e is not null)
    .Select(static (e, _) => e!)
    .WithComparer(EntityModelComparer.Instance);  // value equality

This ensures that typing in one file does not cause regeneration of all entities -- only the changed entity is reprocessed.

The Self-Describing Property of M3

A final elegance: the meta-metamodel describes itself. The MetaConceptAttribute is itself a meta-concept:

// M3 can describe its own structure
[MetaConcept("MetaConcept")]  // self-referential
public sealed class MetaConceptAttribute : Attribute
{
    [MetaProperty("Name", Required = true)]
    public string Name { get; }
    // ...
}

This means the M3 source generator can validate its own inputs. If someone adds a new M3 primitive, the generator validates that it conforms to the M3 rules. The system bootstraps itself.

Diagram

This is the same property that MOF has: the meta-metamodel is reflexive. It is a fixed point in the abstraction hierarchy. You don't need an M4.

Summary

Layer What it defines Who writes it Example
M3 The primitives for building DSLs Framework authors (once) MetaConcept, MetaProperty, MetaReference
M2 A specific DSL (metamodel) DSL authors Entity, Command, DomainEvent
M1 Domain models using the DSL Application developers Customer, Order, CreateCustomerCommand
M0 Runtime instances The program at runtime customer.Name == "Acme Corp"
Diagram

The key architectural decisions:

  1. Attributes as DSL surface -- zero new syntax, full IDE support, compile-time type checking
  2. M3 meta-metamodel -- a shared vocabulary that all DSLs are built from, enabling validation, tooling reuse, and DSL composition
  3. Multi-staged source generators -- a pipeline where each stage builds on the previous, from metamodel registration through validation to code generation
  4. Compile-time validation -- invalid models produce compiler errors, not runtime exceptions
  5. Zero runtime overhead -- all interpretation happens at compile time; the generated code is direct, concrete, and ahead-of-time optimized

This architecture turns the C# compiler into a DSL compiler. Your domain language is checked at build time, and the output is the same efficient code you would have written by hand -- but derived from a declarative, validated model.

This M3 foundation powers concrete DSLs across the FrenchExDev ecosystem: DDD entity modeling, requirements and feature tracking, and the full Content Management Framework with its six M2 DSLs.