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// 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=30M0 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;
}// 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// 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 propertyIn 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.
Each layer is an instance of the layer above it. M1 models conform to M2 metamodels, which conform to M3. M3 conforms to itself.
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:
- You define M3 once -- the structural primitives for building any metamodel
- DSL authors define M2 -- using M3 primitives, expressed as C# attributes
- Source generators compile M2 + M1 -- producing fully typed C# code
- End users write M1 -- using the DSL surface (attributes on their classes)
- 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 }// ============================================================
// 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.
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;
}// ============================================================
// 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.
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; }
}// ============================================================
// 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:
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.
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.csMySolution.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.csThe .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><!-- 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><!-- 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:
- MyDsl.Meta compiles first (no generators, just attribute classes)
- MyDsl.Meta.Generators compiles (the Stage 0 generator assembly)
- MyDsl.EntityDsl compiles -- Stage 0 runs here, reading
[MetaConcept]from the M3 assembly and emittingMetamodelRegistry.g.csinto this project's compilation - MyDsl.EntityDsl.Generators compiles (the Stage 1+2 generator assembly)
- MyApp.Domain compiles -- Stage 1+2 runs here, reading
[Entity]from hand-written classes andMetamodelRegistryfrom 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:
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);
});
}
}[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.
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
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 scriptsStage 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 scriptsStage 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());
}
}[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);
}
}
}
}[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());
}
}[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);
});
}
}[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) │
└──────────────────────────────────────────────────────────────────┘┌──────────────────────────────────────────────────────────────────┐
│ 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:
// 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; }
}// 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);
}
}// 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:
- Filter early -- use
ForAttributeWithMetadataNameto avoid scanning the entire syntax tree - Cache aggressively -- use
WithComparerto avoid regeneration when inputs haven't changed - 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// 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 equalityThis 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; }
// ...
}// 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.
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" |
The key architectural decisions:
- Attributes as DSL surface -- zero new syntax, full IDE support, compile-time type checking
- M3 meta-metamodel -- a shared vocabulary that all DSLs are built from, enabling validation, tooling reuse, and DSL composition
- Multi-staged source generators -- a pipeline where each stage builds on the previous, from metamodel registration through validation to code generation
- Compile-time validation -- invalid models produce compiler errors, not runtime exceptions
- 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.