Source Generators 101
"A Source Generator is a compiler plugin that reads your code and writes more code — at compile time, not runtime. No reflection. No runtime cost. No magic."
Part I showed what Entity.Dsl produces. This part shows how it produces it. If you want to understand Source Generators, or if you want to build your own DSL, this is the foundation.
What Is a Source Generator?
A .NET Source Generator is a component that plugs into the C# compiler (Roslyn). During compilation, the generator:
- Reads the syntax tree and semantic model of your code
- Analyzes attributes, types, and relationships
- Emits new C# source files that become part of the compilation
The key insight: generated code is compiled alongside your code. It is not a pre-build step, not a T4 template, not a runtime reflection trick. The compiler runs the generator as part of its pipeline, and the emitted files are first-class citizens — they get IntelliSense, they participate in type checking, and they appear in the IDE.
What Source Generators Cannot Do
Source Generators have deliberate constraints:
- Cannot modify existing source files (they can only add new files)
- Cannot read files from disk (only the compilation's syntax trees and additional files)
- Cannot reference runtime state (no reflection, no DI container, no database)
- Cannot call async APIs (the generator runs synchronously in the compiler pipeline)
- Must be deterministic — same input must produce same output
These constraints are features, not limitations. They guarantee that generated code is reproducible and that the generator cannot corrupt the developer's source files.
ISourceGenerator vs IIncrementalGenerator
The first generation of Source Generators used ISourceGenerator. It worked, but it had a fatal flaw: the generator ran on every keystroke in the IDE, reanalyzing the entire compilation each time.
.NET 6 introduced IIncrementalGenerator — a pipeline-based API that caches intermediate results and only re-runs the parts of the pipeline affected by changes. For a developer typing in one file, only the entities in that file are re-analyzed. Everything else is cached.
Entity.Dsl uses IIncrementalGenerator exclusively.
// The generator entry point
[Generator]
public sealed class EntityDslGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Define the pipeline here — runs once at generator initialization.
// The pipeline stages run incrementally on each compilation.
}
}// The generator entry point
[Generator]
public sealed class EntityDslGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Define the pipeline here — runs once at generator initialization.
// The pipeline stages run incrementally on each compilation.
}
}The Initialize method is called once when the generator is loaded. It defines a pipeline of transformations. The Roslyn compiler then invokes the pipeline stages incrementally as the code changes.
The Performance Difference
| ISourceGenerator | IIncrementalGenerator | |
|---|---|---|
| Re-analysis | Entire compilation on every change | Only changed nodes |
| Caching | None | Automatic via value equality |
| IDE impact | Noticeable lag on large projects | Negligible |
| API style | Callback-based | Pipeline (LINQ-like) |
For Entity.Dsl, which may analyze 50+ entities in a large project, incremental generation is not optional — it is essential.
ForAttributeWithMetadataName
The most important API in incremental generation is ForAttributeWithMetadataName. It tells Roslyn: "watch for classes (or properties, or methods) decorated with this specific attribute, and give me their semantic information."
Entity.Dsl uses it to discover [Entity] and [AggregateRoot] decorated classes:
private const string EntityAttributeFqn = "FrenchExDev.Net.Ddd.Attributes.EntityAttribute";
private const string AggregateRootAttributeFqn = "FrenchExDev.Net.Ddd.Attributes.AggregateRootAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Discover entities from [Entity]
var entities = context.SyntaxProvider
.ForAttributeWithMetadataName(
EntityAttributeFqn,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ExtractEntityModel(ctx, ct))
.Where(static m => m is not null)
.Select(static (m, _) => m!);
// 2. Discover aggregate roots from [AggregateRoot]
var aggregateRoots = context.SyntaxProvider
.ForAttributeWithMetadataName(
AggregateRootAttributeFqn,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ExtractEntityModel(ctx, ct))
.Where(static m => m is not null)
.Select(static (m, _) => m!);
}private const string EntityAttributeFqn = "FrenchExDev.Net.Ddd.Attributes.EntityAttribute";
private const string AggregateRootAttributeFqn = "FrenchExDev.Net.Ddd.Attributes.AggregateRootAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Discover entities from [Entity]
var entities = context.SyntaxProvider
.ForAttributeWithMetadataName(
EntityAttributeFqn,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ExtractEntityModel(ctx, ct))
.Where(static m => m is not null)
.Select(static (m, _) => m!);
// 2. Discover aggregate roots from [AggregateRoot]
var aggregateRoots = context.SyntaxProvider
.ForAttributeWithMetadataName(
AggregateRootAttributeFqn,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ExtractEntityModel(ctx, ct))
.Where(static m => m is not null)
.Select(static (m, _) => m!);
}The three parameters:
Fully-qualified attribute name — the string
"FrenchExDev.Net.Ddd.Attributes.EntityAttribute". This must match exactly — Roslyn uses metadata name resolution, not type references.Predicate — a fast syntactic filter.
node is ClassDeclarationSyntaxskips everything that is not a class. This runs on the syntax tree (fast) before the semantic model (slow) is consulted.Transform — extracts the semantic information into a model. This is where
ExtractEntityModelreads attributes, properties, and types from theINamedTypeSymbol.
Why Fully-Qualified Names?
Source Generators run in a separate assembly from the code they analyze. The generator cannot reference the attribute types directly (it targets netstandard2.0, while the attributes may target net10.0). Instead, it uses string-based metadata names to locate attributes by their fully-qualified name.
This is why Entity.Dsl declares its attribute names as constants:
private const string TableAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.TableAttribute";
private const string ColumnAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.ColumnAttribute";
private const string PrimaryKeyAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.PrimaryKeyAttribute";
private const string DbContextAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.DbContextAttribute";private const string TableAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.TableAttribute";
private const string ColumnAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.ColumnAttribute";
private const string PrimaryKeyAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.PrimaryKeyAttribute";
private const string DbContextAttributeFqn = "FrenchExDev.Net.Entity.Dsl.Attributes.DbContextAttribute";Stage 1: Entity Discovery
Two ForAttributeWithMetadataName calls discover [Entity] and [AggregateRoot] classes. Both use the same transform function ExtractEntityModel, because an aggregate root is just an entity with additional semantics.
The results are merged into a single collection:
var allEntities = entities.Collect()
.Combine(aggregateRoots.Collect())
.SelectMany(static (pair, _) => pair.Left.AddRange(pair.Right));var allEntities = entities.Collect()
.Combine(aggregateRoots.Collect())
.SelectMany(static (pair, _) => pair.Left.AddRange(pair.Right));Stage 2: DbContext Discovery
A separate ForAttributeWithMetadataName discovers [DbContext] classes:
var dbContexts = context.SyntaxProvider
.ForAttributeWithMetadataName(
DbContextAttributeFqn,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ExtractDbContextModel(ctx, ct))
.Where(static m => m is not null)
.Select(static (m, _) => m!);var dbContexts = context.SyntaxProvider
.ForAttributeWithMetadataName(
DbContextAttributeFqn,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ExtractDbContextModel(ctx, ct))
.Where(static m => m is not null)
.Select(static (m, _) => m!);Stage 3: Combination
The DbContext and all entities are combined. This is where the generator knows which entities belong to which context — in the current implementation, all entities go into every DbContext (bounded context filtering will narrow this in a future version).
var dbContextWithEntities = dbContexts.Combine(allEntities.Collect());var dbContextWithEntities = dbContexts.Combine(allEntities.Collect());The .Combine() operator creates a pair (DbContextEmitModel, ImmutableArray<EntityEmitModel>). When any entity changes, or when the DbContext changes, this stage re-runs.
Stage 4: Emission
Two RegisterSourceOutput calls emit the generated files:
Per-entity emission (3 configuration files per entity):
context.RegisterSourceOutput(allEntities, static (spc, model) =>
{
spc.AddSource($"{model.ClassName}ConfigurationBase.g.cs",
EntityConfigurationEmitter.EmitBase(model));
spc.AddSource($"{model.ClassName}Configuration.g.cs",
EntityConfigurationEmitter.EmitPartialStub(model));
spc.AddSource($"{model.ClassName}ConfigurationRegistration.g.cs",
EntityConfigurationEmitter.EmitRegistration(model));
});context.RegisterSourceOutput(allEntities, static (spc, model) =>
{
spc.AddSource($"{model.ClassName}ConfigurationBase.g.cs",
EntityConfigurationEmitter.EmitBase(model));
spc.AddSource($"{model.ClassName}Configuration.g.cs",
EntityConfigurationEmitter.EmitPartialStub(model));
spc.AddSource($"{model.ClassName}ConfigurationRegistration.g.cs",
EntityConfigurationEmitter.EmitRegistration(model));
});Per-DbContext emission (DbContext + UoW + repositories):
context.RegisterSourceOutput(dbContextWithEntities, static (spc, pair) =>
{
var (dbCtx, ents) = pair;
// Enrich DbContext with DbSets from collected entities
var enriched = new DbContextEmitModel
{
Namespace = dbCtx.Namespace,
ClassName = dbCtx.ClassName,
BoundedContext = dbCtx.BoundedContext,
DbSets = ents
.Select(e => new DbSetModel
{
EntityTypeFull = e.ClassFullName,
PropertyName = NamingHelper.Pluralize(e.ClassName)
})
.ToList()
};
// Emit DbContext files
spc.AddSource($"{dbCtx.ClassName}Base.g.cs",
DbContextEmitter.EmitBase(enriched));
spc.AddSource($"{dbCtx.ClassName}.g.cs",
DbContextEmitter.EmitPartialStub(enriched));
spc.AddSource($"{dbCtx.ClassName}Registration.g.cs",
DbContextRegistrationEmitter.Emit(enriched));
// Emit UnitOfWork files
// ... UnitOfWorkEmitter calls
// Emit repository files per entity
foreach (var ent in ents)
{
spc.AddSource($"I{ent.ClassName}Repository.g.cs",
RepositoryEmitter.EmitInterface(repoModel));
spc.AddSource($"{ent.ClassName}Repository.g.cs",
RepositoryEmitter.EmitPartialStub(repoModel));
}
});context.RegisterSourceOutput(dbContextWithEntities, static (spc, pair) =>
{
var (dbCtx, ents) = pair;
// Enrich DbContext with DbSets from collected entities
var enriched = new DbContextEmitModel
{
Namespace = dbCtx.Namespace,
ClassName = dbCtx.ClassName,
BoundedContext = dbCtx.BoundedContext,
DbSets = ents
.Select(e => new DbSetModel
{
EntityTypeFull = e.ClassFullName,
PropertyName = NamingHelper.Pluralize(e.ClassName)
})
.ToList()
};
// Emit DbContext files
spc.AddSource($"{dbCtx.ClassName}Base.g.cs",
DbContextEmitter.EmitBase(enriched));
spc.AddSource($"{dbCtx.ClassName}.g.cs",
DbContextEmitter.EmitPartialStub(enriched));
spc.AddSource($"{dbCtx.ClassName}Registration.g.cs",
DbContextRegistrationEmitter.Emit(enriched));
// Emit UnitOfWork files
// ... UnitOfWorkEmitter calls
// Emit repository files per entity
foreach (var ent in ents)
{
spc.AddSource($"I{ent.ClassName}Repository.g.cs",
RepositoryEmitter.EmitInterface(repoModel));
spc.AddSource($"{ent.ClassName}Repository.g.cs",
RepositoryEmitter.EmitPartialStub(repoModel));
}
});Emit Models: The Generator's DTOs
A critical design decision in Entity.Dsl: the emit models are plain DTOs that cross the assembly boundary between the generator and the emitters.
The generator lives in FrenchExDev.Net.Entity.Dsl.SourceGenerator (the Roslyn analyzer assembly). The emitters live in FrenchExDev.Net.Entity.Dsl.SourceGenerator.Lib (a regular library). The emit models are the contract between them.
EntityEmitModel
Carries everything about an entity that the emitters need:
public class EntityEmitModel
{
public string Namespace { get; set; } = "";
public string ClassName { get; set; } = "";
public string ClassFullName { get; set; } = "";
public string? TableName { get; set; }
public string? Schema { get; set; }
public List<KeyPropertyModel> PrimaryKeyProperties { get; set; } = new();
public List<PropertyConfigModel> Properties { get; set; } = new();
}public class EntityEmitModel
{
public string Namespace { get; set; } = "";
public string ClassName { get; set; } = "";
public string ClassFullName { get; set; } = "";
public string? TableName { get; set; }
public string? Schema { get; set; }
public List<KeyPropertyModel> PrimaryKeyProperties { get; set; } = new();
public List<PropertyConfigModel> Properties { get; set; } = new();
}KeyPropertyModel
public class KeyPropertyModel
{
public string PropertyName { get; set; } = "";
public int Order { get; set; }
public string ValueGenerated { get; set; } = "OnAdd";
}public class KeyPropertyModel
{
public string PropertyName { get; set; } = "";
public int Order { get; set; }
public string ValueGenerated { get; set; } = "OnAdd";
}PropertyConfigModel
public class PropertyConfigModel
{
public string PropertyName { get; set; } = "";
public string? ColumnName { get; set; }
public string? ColumnType { get; set; }
public int ColumnOrder { get; set; } = -1;
public bool IsRequired { get; set; }
public int MaxLength { get; set; }
public bool IsNotMapped { get; set; }
}public class PropertyConfigModel
{
public string PropertyName { get; set; } = "";
public string? ColumnName { get; set; }
public string? ColumnType { get; set; }
public int ColumnOrder { get; set; } = -1;
public bool IsRequired { get; set; }
public int MaxLength { get; set; }
public bool IsNotMapped { get; set; }
}Why Separate Emit Models?
Three reasons:
Testability: The emitters can be unit-tested with handcrafted emit models — no Roslyn compilation needed. Entity.Dsl's test suite creates
EntityEmitModelinstances and asserts on the emitted strings.DSL-to-DSL generation: Another source generator can produce
EntityEmitModelinstances programmatically and feed them to the emitters. This enables meta-generation — a higher-level DSL can target Entity.Dsl's emit models as an intermediate representation.Separation of concerns: The generator reads Roslyn symbols and populates DTOs. The emitters read DTOs and produce strings. Neither knows about the other's internals.
The Emitter Pattern
Each emitter is a static class with methods that take an emit model and return a string. The string is the complete content of a .g.cs file.
Here is a simplified walkthrough of EntityConfigurationEmitter.EmitBase:
public static string EmitBase(EntityEmitModel model)
{
var sb = new StringBuilder(4096);
// File header
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
// Namespace
sb.AppendLine($"namespace {model.Namespace}.Configuration;");
sb.AppendLine();
// Class declaration
sb.AppendLine($"public abstract class {model.ClassName}ConfigurationBase");
sb.AppendLine("{");
// PreConfigure / PostConfigure hooks
EmitHook(sb, "PreConfigure", model);
EmitHook(sb, "PostConfigure", model);
// ConfigureTable
EmitConfigureTable(sb, model);
// ConfigurePrimaryKey
EmitConfigurePrimaryKey(sb, model);
// Per-property Configure methods
foreach (var prop in model.Properties)
{
if (prop.IsNotMapped) continue;
EmitConfigureProperty(sb, model, prop);
}
// Orchestrator: calls all Configure methods in order
EmitOrchestrator(sb, model);
sb.AppendLine("}");
return sb.ToString();
}public static string EmitBase(EntityEmitModel model)
{
var sb = new StringBuilder(4096);
// File header
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
// Namespace
sb.AppendLine($"namespace {model.Namespace}.Configuration;");
sb.AppendLine();
// Class declaration
sb.AppendLine($"public abstract class {model.ClassName}ConfigurationBase");
sb.AppendLine("{");
// PreConfigure / PostConfigure hooks
EmitHook(sb, "PreConfigure", model);
EmitHook(sb, "PostConfigure", model);
// ConfigureTable
EmitConfigureTable(sb, model);
// ConfigurePrimaryKey
EmitConfigurePrimaryKey(sb, model);
// Per-property Configure methods
foreach (var prop in model.Properties)
{
if (prop.IsNotMapped) continue;
EmitConfigureProperty(sb, model, prop);
}
// Orchestrator: calls all Configure methods in order
EmitOrchestrator(sb, model);
sb.AppendLine("}");
return sb.ToString();
}The per-property method builds a fluent chain:
private static void EmitConfigureProperty(
StringBuilder sb, EntityEmitModel model, PropertyConfigModel prop)
{
// Method signature
sb.AppendLine($" protected virtual void Configure{prop.PropertyName}(");
sb.AppendLine($" EntityTypeBuilder<{model.ClassFullName}> builder)");
sb.AppendLine(" {");
// Build the fluent chain
var chain = new List<string>();
if (prop.ColumnName != null)
chain.Add($".HasColumnName(\"{prop.ColumnName}\")");
if (prop.ColumnType != null)
chain.Add($".HasColumnType(\"{prop.ColumnType}\")");
if (prop.IsRequired)
chain.Add(".IsRequired()");
if (prop.MaxLength > 0)
chain.Add($".HasMaxLength({prop.MaxLength})");
// Emit the chain
if (chain.Count > 0)
{
sb.Append($" builder.Property(e => e.{prop.PropertyName})");
foreach (var c in chain)
{
sb.AppendLine();
sb.Append($" {c}");
}
sb.AppendLine(";");
}
sb.AppendLine(" }");
}private static void EmitConfigureProperty(
StringBuilder sb, EntityEmitModel model, PropertyConfigModel prop)
{
// Method signature
sb.AppendLine($" protected virtual void Configure{prop.PropertyName}(");
sb.AppendLine($" EntityTypeBuilder<{model.ClassFullName}> builder)");
sb.AppendLine(" {");
// Build the fluent chain
var chain = new List<string>();
if (prop.ColumnName != null)
chain.Add($".HasColumnName(\"{prop.ColumnName}\")");
if (prop.ColumnType != null)
chain.Add($".HasColumnType(\"{prop.ColumnType}\")");
if (prop.IsRequired)
chain.Add(".IsRequired()");
if (prop.MaxLength > 0)
chain.Add($".HasMaxLength({prop.MaxLength})");
// Emit the chain
if (chain.Count > 0)
{
sb.Append($" builder.Property(e => e.{prop.PropertyName})");
foreach (var c in chain)
{
sb.AppendLine();
sb.Append($" {c}");
}
sb.AppendLine(";");
}
sb.AppendLine(" }");
}This approach — StringBuilder with explicit formatting — is verbose but predictable. Every generated file has consistent indentation, consistent line breaks, and consistent ordering. The developer reading generated code sees a clean, readable file, not a template artifact.
Why Not Use Templates?
Many source generators use string templates (interpolated strings, Scriban, T4). Entity.Dsl uses StringBuilder for three reasons:
- Performance:
StringBuilderallocates once and appends. Templates parse a template string on every invocation. - Debuggability: A breakpoint on
sb.AppendLine()shows you exactly which line is being emitted. Template engines abstract this away. - Conditional logic: The emitter has many conditional paths (composite keys, nullable properties, schema overrides). In a template, this becomes a nest of
{{#if}}blocks. In C#, it is normal control flow.
The ExtractEntityModel Transform
The transform function bridges Roslyn's semantic model to Entity.Dsl's emit models. Here is the actual implementation:
private static EntityEmitModel? ExtractEntityModel(
GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol typeSymbol)
return null;
var model = new EntityEmitModel
{
Namespace = typeSymbol.ContainingNamespace.ToDisplayString(),
ClassName = typeSymbol.Name,
ClassFullName = $"global::{typeSymbol.ToDisplayString()}"
};
// Read [Table] attribute
var tableAttr = typeSymbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == TableAttributeFqn);
if (tableAttr != null)
{
foreach (var named in tableAttr.NamedArguments)
{
if (named.Key == "Name" && named.Value.Value is string tableName)
model.TableName = tableName;
if (named.Key == "Schema" && named.Value.Value is string schema)
model.Schema = schema;
}
}
// Read properties
foreach (var member in typeSymbol.GetMembers().OfType<IPropertySymbol>())
{
ct.ThrowIfCancellationRequested();
if (member.IsStatic || member.DeclaredAccessibility != Accessibility.Public)
continue;
var attrs = member.GetAttributes();
// Check [PrimaryKey]
var pkAttr = attrs.FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == PrimaryKeyAttributeFqn);
if (pkAttr != null)
{
var keyModel = new KeyPropertyModel { PropertyName = member.Name };
foreach (var named in pkAttr.NamedArguments)
{
if (named.Key == "Order" && named.Value.Value is int order)
keyModel.Order = order;
if (named.Key == "ValueGenerated" && named.Value.Value is int vg)
keyModel.ValueGenerated = ((ValueGenerationEnum)vg).ToString();
}
model.PrimaryKeyProperties.Add(keyModel);
continue;
}
// Check [Column] and [Property] attributes
// ... populate PropertyConfigModel
}
return model;
}private static EntityEmitModel? ExtractEntityModel(
GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol typeSymbol)
return null;
var model = new EntityEmitModel
{
Namespace = typeSymbol.ContainingNamespace.ToDisplayString(),
ClassName = typeSymbol.Name,
ClassFullName = $"global::{typeSymbol.ToDisplayString()}"
};
// Read [Table] attribute
var tableAttr = typeSymbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == TableAttributeFqn);
if (tableAttr != null)
{
foreach (var named in tableAttr.NamedArguments)
{
if (named.Key == "Name" && named.Value.Value is string tableName)
model.TableName = tableName;
if (named.Key == "Schema" && named.Value.Value is string schema)
model.Schema = schema;
}
}
// Read properties
foreach (var member in typeSymbol.GetMembers().OfType<IPropertySymbol>())
{
ct.ThrowIfCancellationRequested();
if (member.IsStatic || member.DeclaredAccessibility != Accessibility.Public)
continue;
var attrs = member.GetAttributes();
// Check [PrimaryKey]
var pkAttr = attrs.FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == PrimaryKeyAttributeFqn);
if (pkAttr != null)
{
var keyModel = new KeyPropertyModel { PropertyName = member.Name };
foreach (var named in pkAttr.NamedArguments)
{
if (named.Key == "Order" && named.Value.Value is int order)
keyModel.Order = order;
if (named.Key == "ValueGenerated" && named.Value.Value is int vg)
keyModel.ValueGenerated = ((ValueGenerationEnum)vg).ToString();
}
model.PrimaryKeyProperties.Add(keyModel);
continue;
}
// Check [Column] and [Property] attributes
// ... populate PropertyConfigModel
}
return model;
}Key patterns:
ctx.TargetSymbol: The RoslynINamedTypeSymbolfor the attributed class. This gives access to the full semantic model — namespace, members, attributes, base types, interfaces.global::prefix: All type references in generated code use theglobal::prefix to avoid namespace conflicts.global::Marketplace.Domain.Productis unambiguous regardless ofusingdirectives in the generated file.ct.ThrowIfCancellationRequested(): Source generators respect cancellation. If the user types another character before the generator finishes, Roslyn cancels the current run and starts a new one.- Named arguments: Attribute properties are read as
NamedArguments— key-value pairs. This is how the generator reads[Table(Name = "Products", Schema = "catalog")]without referencing theTableAttributetype.
Incremental Caching
The power of IIncrementalGenerator comes from its caching. Between the ForAttributeWithMetadataName call and the RegisterSourceOutput call, every intermediate result is cached and compared by value.
When the developer edits Product.cs, Roslyn:
- Re-parses only
Product.cs - Re-runs the
ForAttributeWithMetadataNamepredicate for nodes in that file - Re-runs the
ExtractEntityModeltransform for the matched node - Compares the new
EntityEmitModelto the cached one - If equal: skips emission entirely. If different: re-emits only
Product's files.
For the comparison to work, emit models must implement value equality. This means:
- Two
EntityEmitModelinstances with the same namespace, class name, properties, and keys are considered equal - If a developer adds a space to a comment in
Product.cs(no semantic change), the emit model is identical to the cached one, and no files are re-emitted - If the developer adds a new property, the emit model changes, and only that entity's files are re-emitted
This is why emit models are kept simple — no Roslyn symbols (which are reference-equal, not value-equal), no mutable collections, no lambdas.
What Gets Cached vs Re-Run
| Pipeline Stage | Cached? | Re-runs when... |
|---|---|---|
| Syntax tree parse | Per-file | File text changes |
| ForAttributeWithMetadataName predicate | Per-node | Containing file changes |
| ExtractEntityModel transform | Per-attributed-class | Class or its attributes change |
| Combine/Collect | Per-combination | Any input changes |
| RegisterSourceOutput (emission) | Per-model | Emit model changes (value equality) |
Diagnostic Reporting
Source Generators can report diagnostics — compiler errors and warnings that appear in the IDE and build output. Entity.Dsl uses diagnostics to catch configuration mistakes at compile time.
How Diagnostics Are Defined
Each diagnostic has a unique ID, a severity, and a message format:
// Examples of Entity.Dsl diagnostics
public static readonly DiagnosticDescriptor TableWithoutEntity = new(
id: "EDSL0001",
title: "[Table] requires [Entity] or [AggregateRoot]",
messageFormat: "Class '{0}' has [Table] but no [Entity] or [AggregateRoot] attribute",
category: "Entity.Dsl",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public static readonly DiagnosticDescriptor NotMappedOnPrimaryKey = new(
id: "EDSL0027",
title: "[NotMapped] on [PrimaryKey]",
messageFormat: "Property '{0}' on '{1}' has both [NotMapped] and [PrimaryKey] — these are contradictory",
category: "Entity.Dsl",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public static readonly DiagnosticDescriptor DefaultValueConflict = new(
id: "EDSL0004",
title: "DefaultValue conflict",
messageFormat: "Property '{0}' on '{1}' has both Value and Sql on [DefaultValue] — use one or the other",
category: "Entity.Dsl",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);// Examples of Entity.Dsl diagnostics
public static readonly DiagnosticDescriptor TableWithoutEntity = new(
id: "EDSL0001",
title: "[Table] requires [Entity] or [AggregateRoot]",
messageFormat: "Class '{0}' has [Table] but no [Entity] or [AggregateRoot] attribute",
category: "Entity.Dsl",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public static readonly DiagnosticDescriptor NotMappedOnPrimaryKey = new(
id: "EDSL0027",
title: "[NotMapped] on [PrimaryKey]",
messageFormat: "Property '{0}' on '{1}' has both [NotMapped] and [PrimaryKey] — these are contradictory",
category: "Entity.Dsl",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public static readonly DiagnosticDescriptor DefaultValueConflict = new(
id: "EDSL0004",
title: "DefaultValue conflict",
messageFormat: "Property '{0}' on '{1}' has both Value and Sql on [DefaultValue] — use one or the other",
category: "Entity.Dsl",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);How Diagnostics Are Reported
In the transform function or in a validation stage, the generator reports diagnostics via the SourceProductionContext:
context.RegisterSourceOutput(allEntities, static (spc, model) =>
{
// Validate before emitting
if (model.TableName != null && !model.HasEntityOrAggregateRoot)
{
spc.ReportDiagnostic(Diagnostic.Create(
TableWithoutEntity,
model.Location,
model.ClassName));
return; // Skip emission for invalid models
}
// Emit normally
spc.AddSource(...);
});context.RegisterSourceOutput(allEntities, static (spc, model) =>
{
// Validate before emitting
if (model.TableName != null && !model.HasEntityOrAggregateRoot)
{
spc.ReportDiagnostic(Diagnostic.Create(
TableWithoutEntity,
model.Location,
model.ClassName));
return; // Skip emission for invalid models
}
// Emit normally
spc.AddSource(...);
});The developer sees these as red squiggles in the IDE and as build errors in the terminal. They are indistinguishable from regular compiler errors — but they come from the generator, not from the C# language.
Example: What the Developer Sees
error EDSL0001: Class 'Product' has [Table] but no [Entity] or [AggregateRoot] attribute
error EDSL0027: Property 'Id' on 'Order' has both [NotMapped] and [PrimaryKey] — these are contradictoryerror EDSL0001: Class 'Product' has [Table] but no [Entity] or [AggregateRoot] attribute
error EDSL0027: Property 'Id' on 'Order' has both [NotMapped] and [PrimaryKey] — these are contradictoryInspecting Generated Output in the IDE
Visual Studio and Rider show generated files under the Analyzers node in Solution Explorer:
Dependencies
└─ Analyzers
└─ FrenchExDev.Net.Entity.Dsl.SourceGenerator
└─ FrenchExDev.Net.Entity.Dsl.SourceGenerator.EntityDslGenerator
├─ ProductConfigurationBase.g.cs
├─ ProductConfiguration.g.cs
├─ ProductConfigurationRegistration.g.cs
├─ IProductRepository.g.cs
├─ ProductRepository.g.cs
├─ MarketplaceDbContextBase.g.cs
├─ MarketplaceDbContext.g.cs
├─ MarketplaceDbContextRegistration.g.cs
├─ IMarketplaceDbContextUnitOfWork.g.cs
├─ MarketplaceDbContextUnitOfWorkBase.g.cs
└─ MarketplaceDbContextUnitOfWork.g.csDependencies
└─ Analyzers
└─ FrenchExDev.Net.Entity.Dsl.SourceGenerator
└─ FrenchExDev.Net.Entity.Dsl.SourceGenerator.EntityDslGenerator
├─ ProductConfigurationBase.g.cs
├─ ProductConfiguration.g.cs
├─ ProductConfigurationRegistration.g.cs
├─ IProductRepository.g.cs
├─ ProductRepository.g.cs
├─ MarketplaceDbContextBase.g.cs
├─ MarketplaceDbContext.g.cs
├─ MarketplaceDbContextRegistration.g.cs
├─ IMarketplaceDbContextUnitOfWork.g.cs
├─ MarketplaceDbContextUnitOfWorkBase.g.cs
└─ MarketplaceDbContextUnitOfWork.g.csYou can click on any file to read it, set breakpoints in it, and step through it during debugging. Generated code is real code.
Writing Generated Files to Disk
Add this to your .csproj to write generated files to the obj folder:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
</PropertyGroup><PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
</PropertyGroup>This is useful for:
- Reviewing generated code in pull requests (check in the output)
- Diffing generated output between versions
- Debugging when the IDE's Analyzers node does not refresh
Attaching a Debugger
To debug the generator itself (not the generated code), add this to the generator's source:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
System.Diagnostics.Debugger.Launch();
}
#endif
// ... pipeline definition
}public void Initialize(IncrementalGeneratorInitializationContext context)
{
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
System.Diagnostics.Debugger.Launch();
}
#endif
// ... pipeline definition
}When the project builds, a debugger-attach dialog appears. You can then step through the generator's Initialize method, the ExtractEntityModel transform, and the emitters.
Warning: This launches on every build. Remove the Debugger.Launch() call before committing.
Common Debugging Issues
| Symptom | Cause | Fix |
|---|---|---|
| Generated files do not appear | Generator assembly not loaded | Check OutputItemType="Analyzer" in PackageReference |
| Files appear but are empty | Transform returned null | Add null checks and diagnostics in transform |
| Stale output after code change | Emit model equality not working | Ensure emit models use value equality |
| IDE shows errors in generated code | Namespace mismatch | Check global:: prefixes and namespace computation |
| Generator crashes silently | Unhandled exception in transform | Wrap in try/catch, report diagnostic on error |
The NamingHelper
Entity.Dsl includes a utility for naming conventions — pluralization, snake_case, and camelCase conversion:
public static class NamingHelper
{
// Pluralize entity names for DbSet properties
// "Product" → "Products"
// "Category" → "Categories"
// "Address" → "Addresses"
// "Person" → "People"
public static string Pluralize(string name) { ... }
// Convert PascalCase to snake_case for database columns
// "OrderNumber" → "order_number"
// "CreatedAt" → "created_at"
public static string ToSnakeCase(string name) { ... }
// Convert PascalCase to camelCase for JSON properties
// "OrderNumber" → "orderNumber"
public static string ToCamelCase(string name) { ... }
}public static class NamingHelper
{
// Pluralize entity names for DbSet properties
// "Product" → "Products"
// "Category" → "Categories"
// "Address" → "Addresses"
// "Person" → "People"
public static string Pluralize(string name) { ... }
// Convert PascalCase to snake_case for database columns
// "OrderNumber" → "order_number"
// "CreatedAt" → "created_at"
public static string ToSnakeCase(string name) { ... }
// Convert PascalCase to camelCase for JSON properties
// "OrderNumber" → "orderNumber"
public static string ToCamelCase(string name) { ... }
}The pluralizer handles common English patterns (consonant+y → ies, s/x/z → es, etc.) and is used by the generator to derive DbSet property names from entity class names.
The Project Structure
Entity.Dsl is organized into four assemblies:
Entity.Dsl/
├── src/
│ ├── FrenchExDev.Net.Entity.Dsl.Attributes/ (netstandard2.0;net10.0)
│ │ ├── TableAttribute.cs
│ │ ├── ColumnAttribute.cs
│ │ ├── PrimaryKeyAttribute.cs
│ │ ├── DbContextAttribute.cs
│ │ └── ValueGeneration.cs
│ │
│ ├── FrenchExDev.Net.Entity.Dsl.Abstractions/ (net8.0;net10.0)
│ │ ├── IRepository.cs — full read-write repository
│ │ ├── IReadOnlyRepository.cs — CQRS-friendly read-only
│ │ ├── RepositoryBase.cs — generic implementation
│ │ ├── IUnitOfWork.cs — SaveChanges + transactions
│ │ ├── ISpecification.cs — reusable query criteria
│ │ ├── IEntityListener.cs — pre/post save hooks
│ │ └── ICurrentUserProvider.cs — for [Blameable] behavior
│ │
│ ├── FrenchExDev.Net.Entity.Dsl.SourceGenerator/ (netstandard2.0)
│ │ └── EntityDslGenerator.cs — IIncrementalGenerator entry point
│ │
│ └── FrenchExDev.Net.Entity.Dsl.SourceGenerator.Lib/ (netstandard2.0)
│ ├── EntityEmitModel.cs — entity DTO
│ ├── DbContextEmitModel.cs — DbContext DTO
│ ├── RepositoryEmitModel.cs — repository DTO
│ ├── UnitOfWorkEmitModel.cs — UoW DTO
│ ├── EntityConfigurationEmitter.cs — emits Configuration files
│ ├── DbContextEmitter.cs — emits DbContext files
│ ├── DbContextRegistrationEmitter.cs — emits DI extension
│ ├── RepositoryEmitter.cs — emits Repository files
│ ├── UnitOfWorkEmitter.cs — emits UoW files
│ └── NamingHelper.cs — pluralization, casingEntity.Dsl/
├── src/
│ ├── FrenchExDev.Net.Entity.Dsl.Attributes/ (netstandard2.0;net10.0)
│ │ ├── TableAttribute.cs
│ │ ├── ColumnAttribute.cs
│ │ ├── PrimaryKeyAttribute.cs
│ │ ├── DbContextAttribute.cs
│ │ └── ValueGeneration.cs
│ │
│ ├── FrenchExDev.Net.Entity.Dsl.Abstractions/ (net8.0;net10.0)
│ │ ├── IRepository.cs — full read-write repository
│ │ ├── IReadOnlyRepository.cs — CQRS-friendly read-only
│ │ ├── RepositoryBase.cs — generic implementation
│ │ ├── IUnitOfWork.cs — SaveChanges + transactions
│ │ ├── ISpecification.cs — reusable query criteria
│ │ ├── IEntityListener.cs — pre/post save hooks
│ │ └── ICurrentUserProvider.cs — for [Blameable] behavior
│ │
│ ├── FrenchExDev.Net.Entity.Dsl.SourceGenerator/ (netstandard2.0)
│ │ └── EntityDslGenerator.cs — IIncrementalGenerator entry point
│ │
│ └── FrenchExDev.Net.Entity.Dsl.SourceGenerator.Lib/ (netstandard2.0)
│ ├── EntityEmitModel.cs — entity DTO
│ ├── DbContextEmitModel.cs — DbContext DTO
│ ├── RepositoryEmitModel.cs — repository DTO
│ ├── UnitOfWorkEmitModel.cs — UoW DTO
│ ├── EntityConfigurationEmitter.cs — emits Configuration files
│ ├── DbContextEmitter.cs — emits DbContext files
│ ├── DbContextRegistrationEmitter.cs — emits DI extension
│ ├── RepositoryEmitter.cs — emits Repository files
│ ├── UnitOfWorkEmitter.cs — emits UoW files
│ └── NamingHelper.cs — pluralization, casingWhy netstandard2.0? Source Generators must target netstandard2.0 because the Roslyn compiler (which hosts them) runs on .NET Framework in Visual Studio. The SourceGenerator.Lib assembly also targets netstandard2.0 because it is referenced by the generator.
Why a separate Lib? Roslyn loads generator assemblies in a special context with restricted dependencies. By putting the emitters and emit models in a separate Lib assembly, they can be tested independently with standard unit test frameworks — no Roslyn test infrastructure needed.
Testing the Emitters
Entity.Dsl tests each emitter in isolation by constructing emit models and asserting on the output:
[Fact]
public void EmitBase_SinglePrimaryKey_GeneratesHasKeyAndValueGenerated()
{
var model = new EntityEmitModel
{
Namespace = "TestApp.Domain",
ClassName = "Order",
ClassFullName = "global::TestApp.Domain.Order",
TableName = "Orders",
PrimaryKeyProperties =
{
new KeyPropertyModel
{
PropertyName = "Id",
ValueGenerated = "OnAdd"
}
},
Properties =
{
new PropertyConfigModel
{
PropertyName = "OrderNumber",
IsRequired = true,
MaxLength = 50
}
}
};
var result = EntityConfigurationEmitter.EmitBase(model);
Assert.Contains("builder.HasKey(e => e.Id);", result);
Assert.Contains("builder.Property(e => e.Id).ValueGeneratedOnAdd();", result);
Assert.Contains(".IsRequired()", result);
Assert.Contains(".HasMaxLength(50)", result);
}[Fact]
public void EmitBase_SinglePrimaryKey_GeneratesHasKeyAndValueGenerated()
{
var model = new EntityEmitModel
{
Namespace = "TestApp.Domain",
ClassName = "Order",
ClassFullName = "global::TestApp.Domain.Order",
TableName = "Orders",
PrimaryKeyProperties =
{
new KeyPropertyModel
{
PropertyName = "Id",
ValueGenerated = "OnAdd"
}
},
Properties =
{
new PropertyConfigModel
{
PropertyName = "OrderNumber",
IsRequired = true,
MaxLength = 50
}
}
};
var result = EntityConfigurationEmitter.EmitBase(model);
Assert.Contains("builder.HasKey(e => e.Id);", result);
Assert.Contains("builder.Property(e => e.Id).ValueGeneratedOnAdd();", result);
Assert.Contains(".IsRequired()", result);
Assert.Contains(".HasMaxLength(50)", result);
}This pattern — test the emitter, not the generator — is fast (no Roslyn compilation), reliable (no Roslyn version sensitivity), and focused (tests exactly what the developer cares about: the generated code shape).
Summary
Entity.Dsl's source generator is built on five key patterns:
- IIncrementalGenerator for IDE performance
- ForAttributeWithMetadataName for targeted discovery
- Emit Models as DTOs crossing the generator/emitter boundary
- StringBuilder emitters for predictable, debuggable output
- Value-equality caching for incremental regeneration
The generator reads two kinds of attributes — DDD attributes ([Entity], [AggregateRoot]) from one package and persistence attributes ([Table], [Column], [PrimaryKey]) from another. This separation means the domain model references DDD concepts, not EF Core concepts. The generator bridges the gap.
In the next part, we start building the marketplace domain with aggregate boundaries and lifecycle ownership.