Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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:

  1. Reads the syntax tree and semantic model of your code
  2. Analyzes attributes, types, and relationships
  3. 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.

Diagram

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 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!);
}

The three parameters:

  1. Fully-qualified attribute name — the string "FrenchExDev.Net.Ddd.Attributes.EntityAttribute". This must match exactly — Roslyn uses metadata name resolution, not type references.

  2. Predicate — a fast syntactic filter. node is ClassDeclarationSyntax skips everything that is not a class. This runs on the syntax tree (fast) before the semantic model (slow) is consulted.

  3. Transform — extracts the semantic information into a model. This is where ExtractEntityModel reads attributes, properties, and types from the INamedTypeSymbol.

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";

Entity.Dsl's 4-Stage Pipeline

The generator's Initialize method defines a pipeline with four stages:

Diagram

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));

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!);

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());

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));
});

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));
    }
});

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();
}

KeyPropertyModel

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; }
}

Why Separate Emit Models?

Three reasons:

  1. Testability: The emitters can be unit-tested with handcrafted emit models — no Roslyn compilation needed. Entity.Dsl's test suite creates EntityEmitModel instances and asserts on the emitted strings.

  2. DSL-to-DSL generation: Another source generator can produce EntityEmitModel instances 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.

  3. 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();
}

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("    }");
}

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:

  1. Performance: StringBuilder allocates once and appends. Templates parse a template string on every invocation.
  2. Debuggability: A breakpoint on sb.AppendLine() shows you exactly which line is being emitted. Template engines abstract this away.
  3. 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;
}

Key patterns:

  • ctx.TargetSymbol: The Roslyn INamedTypeSymbol for 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 the global:: prefix to avoid namespace conflicts. global::Marketplace.Domain.Product is unambiguous regardless of using directives 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 the TableAttribute type.

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:

  1. Re-parses only Product.cs
  2. Re-runs the ForAttributeWithMetadataName predicate for nodes in that file
  3. Re-runs the ExtractEntityModel transform for the matched node
  4. Compares the new EntityEmitModel to the cached one
  5. 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 EntityEmitModel instances 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);

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(...);
});

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 contradictory

Inspecting 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.cs

You 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>

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
}

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) { ... }
}

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.csfor [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, casing

Why 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);
}

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:

  1. IIncrementalGenerator for IDE performance
  2. ForAttributeWithMetadataName for targeted discovery
  3. Emit Models as DTOs crossing the generator/emitter boundary
  4. StringBuilder emitters for predictable, debuggable output
  5. 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.