📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you author a source generator in .NET, and how should the generator project be set up and packaged?

Source generator authoring: IIncrementalGenerator, project setup (netstandard2.0, analyser references), post-initialisation output, debugging, packaging and pitfalls.

Senior Coding Advanced code-generation nuget roslyn source-generators

🎯 Key points

  • Implement IIncrementalGenerator with [Generator]; ISourceGenerator is deprecated
  • Generators add source files; they cannot modify existing code
  • Target netstandard2.0 and reference the oldest Roslyn version you need with PrivateAssets=all
  • Reference as an analyser in the same repo or pack into analyzers/dotnet/cs
  • Use RegisterPostInitializationOutput for marker attributes and generate partial types
  • Inspect output with EmitCompilerGeneratedFiles and test with CSharpGeneratorDriver
  • No file or network I/O; use AdditionalFiles for inputs

⏱ 30-second answer

A source generator is a class marked [Generator] that implements IIncrementalGenerator (the older ISourceGenerator is deprecated). It runs inside the compiler, reads the user's code through Roslyn and adds new C# files to the compilation; it cannot modify existing code. The generator project targets netstandard2.0, references Microsoft.CodeAnalysis.CSharp (a version no newer than your consumers' compilers) with PrivateAssets="all", and sets EnforceExtendedAnalyzerRules. Consumers reference it as an analyser (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), or you pack it into analyzers/dotnet/cs in a NuGet package. Use RegisterPostInitializationOutput for marker attributes and EmitCompilerGeneratedFiles to see the output.

🗣 Full answer (senior)

How I would answer this in an interview

A source generator is a class implementing IIncrementalGenerator that Roslyn loads during compilation. It builds an incremental pipeline: select the syntax/symbols it cares about (usually via an attribute), transform them into a small equatable model, and emit source with AddSource. The generator lives in its own netstandard2.0 project referencing Microsoft.CodeAnalysis.CSharp, is packaged as an analyzer in NuGet, and must be tested and versioned carefully because it runs inside every developer's IDE.

1. Project setup

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>          <!-- required: runs inside the compiler/IDE -->
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <IncludeBuildOutput>false</IncludeBuildOutput>             <!-- don't put the DLL in lib/ -->
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.x" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.x" PrivateAssets="all" />
  </ItemGroup>
  <ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
  </ItemGroup>
</Project>
  • netstandard2.0 so it loads in Visual Studio (.NET Framework), Rider and the .NET SDK compiler.
  • Reference the lowest Roslyn version you need to support (the generator only works with SDKs/IDEs that have at least that Roslyn version).
  • Dependencies must be packed alongside (or avoided) — generator dependencies don't flow automatically.
  • Optionally ship the attribute in a separate runtime package, or generate it via RegisterPostInitializationOutput.

Consuming locally (for development): <ProjectReference Include="..\Gen\Gen.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />.

2. A small generator: [GenerateToString]

[Generator]
public sealed class ToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Emit the marker attribute into every consuming compilation
        context.RegisterPostInitializationOutput(static ctx => ctx.AddSource("GenerateToStringAttribute.g.cs", """
            namespace Sangam.Generators;
            [System.AttributeUsage(System.AttributeTargets.Class)]
            internal sealed class GenerateToStringAttribute : System.Attribute { }
            """));

        // 2. Find classes with the attribute and build an equatable model (no Roslyn symbols in the model!)
        var models = context.SyntaxProvider.ForAttributeWithMetadataName(
            "Sangam.Generators.GenerateToStringAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) =>
            {
                var type = (INamedTypeSymbol)ctx.TargetSymbol;
                var props = type.GetMembers().OfType<IPropertySymbol>()
                    .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
                    .Select(p => p.Name).ToImmutableArray();
                return new TypeModel(type.ContainingNamespace.ToDisplayString(), type.Name, new EquatableArray<string>(props));
            });

        // 3. Generate source for each model
        context.RegisterSourceOutput(models, static (spc, m) =>
        {
            var body = string.Join(", ", m.Properties.Select(p => $"{p} = {{{p}}}"));
            spc.AddSource($"{m.Name}.ToString.g.cs", $$"""
                namespace {{m.Namespace}};
                partial class {{m.Name}}
                {
                    public override string ToString() => $"{{m.Name}} { {{body}} }";
                }
                """);
        });
    }
}

internal sealed record TypeModel(string Namespace, string Name, EquatableArray<string> Properties);

Key points:

  • ForAttributeWithMetadataName is the fast, recommended way to find attributed declarations (it uses compiler indexes).
  • The model is a record of strings/values, with an equatable collection wrapper (ImmutableArray<T> uses reference equality) so the pipeline caches results — never store ISymbol, SyntaxNode or Compilation in models.
  • Report errors with spc.ReportDiagnostic(Diagnostic.Create(...)) (e.g. class not partial).
  • Generated hint names must be unique; use .g.cs.

3. Testing

  • Snapshot tests: run the generator with CSharpGeneratorDriver over a test compilation and compare output (Verify.SourceGenerators).
  • Compilation tests: ensure generated code compiles without diagnostics.
  • Incrementality tests: run the driver twice with an unrelated edit and assert steps were Cached/Unchanged (IncrementalStepRunReason) via trackIncrementalGeneratorSteps: true.
  • Diagnostic tests for invalid inputs.
var driver = CSharpGeneratorDriver.Create(new ToStringGenerator()).RunGenerators(compilation);
var result = driver.GetRunResult();

4. Packaging and shipping

  • dotnet pack producing analyzers/dotnet/cs/YourGenerator.dll (and dependencies).
  • Version with SemVer; generated code changes can break consumers — treat output as API.
  • Publish to an internal feed (Azure Artifacts) or NuGet.
  • Document required partial declarations and diagnostics.
  • Consider AOT/trim friendliness of generated code (no reflection).

5. A production example

We replaced runtime reflection that mapped domain events to integration messages with a generator: [IntegrationEvent("orders.placed.v1")] on records generated a JsonSerializerContext registration, a mapping method and a topic registry. The first version stored ISymbols in the pipeline model and made Visual Studio sluggish on large solutions (regenerating on every keystroke). Switching to ForAttributeWithMetadataName and value-equatable models fixed IDE performance, confirmed by incrementality tests.

🔍 What the interviewer is checking

The interviewer checks practical authoring experience, not just awareness.

Green flags: IIncrementalGenerator, netstandard2.0, analyser reference and packaging path, post-initialisation output, partial types, EmitCompilerGeneratedFiles, version pinning, testing with the driver.

Red flags: thinks generators can rewrite user code; targets modern TFMs; does I/O in generators.

📚 Learn in depth

1. Pipeline concepts

  • IncrementalValueProvider<T> (single value) and IncrementalValuesProvider<T> (many).
  • Operators: Select, SelectMany, Where, Combine, Collect, WithComparer, WithTrackingName.
  • Sources: SyntaxProvider (syntax/attribute based), CompilationProvider, AdditionalTextsProvider (files), AnalyzerConfigOptionsProvider (.editorconfig/MSBuild properties via CompilerVisibleProperty), ParseOptionsProvider, MetadataReferencesProvider.
  • Outputs: RegisterSourceOutput, RegisterImplementationSourceOutput (not needed for IDE semantics), RegisterPostInitializationOutput (static source like attributes).

2. Caching rules

Each step's output is compared with the previous run using equality; if equal, downstream steps are skipped. Hence models must have value equality. Combining with CompilationProvider invalidates on every edit — avoid it or extract only what you need.

3. Generator dependencies

If the generator depends on other libraries (e.g. Scriban for templating), they must be packed into the analyzer folder and loadable in the compiler process; conflicts with other analysers' dependencies can occur. Prefer no dependencies.

4. Consumer configuration

Expose options via MSBuild properties: <CompilerVisibleProperty Include="MyGenerator_Namespace" /> in a .props file packed in build/, read via AnalyzerConfigOptionsProvider.

5. Debugging generators

  • IsRoslynComponent + a launch profile with "commandName": "DebugRoslynComponent" lets you debug the generator in VS against a target project.
  • Debugger.Launch() (temporarily) in Initialize.
  • Emit generated files to disk in consumers.

Try it yourself

  1. Build the [GenerateToString] generator and reference it from a sample project.
  2. Add snapshot tests with Verify.
  3. Add an incrementality test that edits an unrelated file.
  4. Pack and consume it via a local NuGet feed.

💻 Code

// Gen.csproj: netstandard2.0, Microsoft.CodeAnalysis.CSharp (PrivateAssets=all)
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[Generator]
public sealed class ToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Marker attribute added to every consuming compilation
        context.RegisterPostInitializationOutput(static ctx => ctx.AddSource("GenerateToStringAttribute.g.cs", """
            // <auto-generated/>
            namespace Demo
            {
                [global::System.AttributeUsage(global::System.AttributeTargets.Class)]
                internal sealed class GenerateToStringAttribute : global::System.Attribute { }
            }
            """));

        // 2. Find annotated classes and 3. reduce them to a simple model (strings only)
        IncrementalValuesProvider<(string? Namespace, string Name, string Properties)> models =
            context.SyntaxProvider.ForAttributeWithMetadataName(
                "Demo.GenerateToStringAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, _) =>
                {
                    var type = (INamedTypeSymbol)ctx.TargetSymbol;
                    string props = string.Join(",", type.GetMembers().OfType<IPropertySymbol>()
                        .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
                        .Select(p => p.Name));
                    string? ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString();
                    return (ns, type.Name, props);
                });

        // 4. Render source from the model
        context.RegisterSourceOutput(models, static (spc, m) =>
        {
            var sb = new StringBuilder("// <auto-generated/>\n#nullable enable\n");
            if (m.Namespace is not null) sb.Append($"namespace {m.Namespace};\n");
            string body = string.Join(", ", m.Properties.Split(new[] { ',' }, System.StringSplitOptions.RemoveEmptyEntries)
                .Select(p => $"{p} = {{{p}}}"));
            sb.Append($"partial class {m.Name}\n{{\n    public override string ToString() => $\"{m.Name} {{{{ {body} }}}}\";\n}}\n");
            spc.AddSource($"{m.Name}.ToString.g.cs", sb.ToString());
        });
    }
}
// Consumer project
using Demo;

Console.WriteLine(new Order { Id = 7, Status = "Paid" }); // Order { Id = 7, Status = Paid }

[GenerateToString]
partial class Order
{
    public int Id { get; init; }
    public string Status { get; init; } = "";
}

⚠ Common mistakes

1. Using ISourceGenerator (v1 API)

What happens: writing non-incremental generators. Why it is wrong: runs on every keystroke; slows the IDE; deprecated pattern. Do instead: IIncrementalGenerator.

2. Storing symbols or syntax in models

What happens: pipeline models contain ISymbol/SyntaxNode. Why it is wrong: breaks caching and keeps compilations alive. Do instead: extract strings/values into equatable records.

3. Targeting modern TFMs

What happens: generator targets net8.0. Why it is wrong: won't load in Visual Studio's compiler host. Do instead: netstandard2.0.

4. Referencing too-new Roslyn versions

What happens: generator requires the latest Roslyn. Why it is wrong: consumers on older SDKs/IDEs can't load it. Do instead: reference the minimum supported version.

5. Packaging as a normal library

What happens: generator DLL in lib/. Why it is wrong: not loaded as an analyzer; may be referenced at run time. Do instead: pack into analyzers/dotnet/cs.

6. Combining with the whole compilation

What happens: Combine(context.CompilationProvider) at the start. Why it is wrong: invalidates on every change. Do instead: use targeted providers.

7. Silent failures

What happens: generator skips invalid inputs without diagnostics. Why it is wrong: confusing missing code. Do instead: report diagnostics.

8. No snapshot tests

What happens: generator output changes unnoticed. Why it is wrong: breaking changes for consumers. Do instead: snapshot and compile tests in CI.

🏛 Senior insight: trade-offs

Generators shift work from run time to build time, with real maintenance cost.

Benefit Cost
No reflection at run time, trimming and AOT friendly Generator code is harder to write and test
Generated code is visible and debuggable Build performance depends on generator quality
Compile-time diagnostics for misuse Version compatibility with consumers' compilers

Seniors use existing generators first (JSON, logging, regex, configuration binding) and write their own only for repeated, stable patterns.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions ForAttributeWithMetadataName Why: Checks incremental pipeline and caching knowledge
    How to answer

    An incremental generator declares a pipeline of transformations (Select, Where, Combine, Collect) over providers such as SyntaxProvider, CompilationProvider and AdditionalTextsProvider. Roslyn caches each step's output and, on every keystroke, reruns a step only if its input changed, comparing values with equality. So the models flowing through the pipeline must be cheap, value-equal types (records of strings, an equatable array wrapper), never ISymbol, SyntaxNode or Compilation, which change every edit and defeat caching. Use ForAttributeWithMetadataName for attribute-driven generators: it is far faster than scanning every syntax node. A slow or non-incremental generator makes typing in the IDE lag.

  2. When: Candidate gives examples of generators they consume Why: Checks the consumer view of built-in generators
    How to answer

    A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.

  3. When: Candidate says generators can report diagnostics Why: Probes analyser authoring
    How to answer

    An analyser is a class deriving from DiagnosticAnalyzer with [DiagnosticAnalyzer(LanguageNames.CSharp)]. It declares DiagnosticDescriptors (id, title, message, category, default severity) and in Initialize calls EnableConcurrentExecution(), ConfigureGeneratedCodeAnalysis(...) and registers callbacks such as RegisterSymbolAction, RegisterSyntaxNodeAction or RegisterOperationAction, reporting with context.ReportDiagnostic. It targets netstandard2.0 like a generator. Test it with Microsoft.CodeAnalysis.CSharp.Analyzer.Testing using markup such as {|DEMO001:Run|}. Ship it as a project reference with OutputItemType="Analyzer" or a NuGet package, and let teams tune severity in .editorconfig.

🛠 Real-world problems: how you handle them

Reflection-based ToString helper replaced by a generator
Situation
Structured logs in a payments service used a reflection helper to print [dozens of] DTO types.
Signals
Profiler showed reflection and boxing in logging paths; trimming warnings blocked an AOT trial.
Investigation
Measured the helper's cost per log call with BenchmarkDotNet.
Resolution
Wrote an incremental generator producing `ToString` for types marked with an attribute, packaged with the shared library.
Prevention
Added generator snapshot tests with `CSharpGeneratorDriver` and Verify.

How to tell it (STAR)

In [our payments service], logging DTOs used reflection. I wrote a small incremental generator that emits ToString at build time. Logging cost per call dropped by [X%], trimming warnings disappeared, and snapshot tests keep the output stable.

↩ Usually asked after

More in Compilation

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me