Source Generators in C#: building a model mapper at compile time

  • .NET 5+
  • Roslyn
Source Generators in C#: building a model mapper at compile time

Source Generators arrived in .NET 5. They're compile-time metaprogramming for C#: a generator sees your project's code and adds new code to it.

Before them, people generated code in different ways, and each had its limits. T4 Templates generate code before compilation, PostSharp and other AOP frameworks modify IL code after it, reflection adds overhead at runtime, and Roslyn Analyzers are built more for analysis than generation.

Source Generators analyze the project code with full access to the semantic model and generate additional code during compilation. That lets you automate routine tasks, drop reflection for better performance, and cut a good share of boilerplate code.

Practical use


As an example, we'll build an automatic model mapper. It's a task that comes up in almost every project.

Our mapper will:

  • Generate code at compile time
  • Work without reflection
  • Support basic mapping of properties by name
  • Maintain type safety

Project settings


First, create a Solution with two projects.

1
2
3
4
5
dotnet new sln -n Mapping
dotnet new classlib -o Mapping.SourceGenerators
dotnet new console -o Mapping.Consumer
dotnet sln add Mapping.SourceGenerators
dotnet sln add Mapping.Consumer

Generator Project settings (Mapping.SourceGenerators.csproj)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        
        <IsPackable>false</IsPackable>
        <Nullable>enable</Nullable>
        <LangVersion>latest</LangVersion>
        
        <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
        <IsRoslynComponent>true</IsRoslynComponent>
        
        <RootNamespace>Mapping.SourceGenerators</RootNamespace>
        <PackageId>Mapping.SourceGenerators</PackageId>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
            <PrivateAssets>all</PrivateAssets>
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0"/>
    </ItemGroup>

</Project>

Three settings that matter here:

1
<TargetFramework>netstandard2.0</TargetFramework>

Source Generators must be compatible with .NET Standard 2.0, so the generator works across different versions of .NET.

1
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>

Enables extra validation rules for analyzers that catch potential performance and compatibility issues. It's recommended for every new Source Generator.

1
<IsRoslynComponent>true</IsRoslynComponent>

Marks the project as a Roslyn compiler component. This turns on optimizations specific to Source Generators and affects how the generator is loaded and run.

Important NuGet packages for Source Generators

Microsoft.CodeAnalysis.Analyzers contains analyzers for building compiler extensions, Source Generators included. They check your generator for common mistakes and point out how to write it efficiently, without performance problems.

Microsoft.CodeAnalysis.CSharp gives you the Roslyn Compiler API. Through it the generator analyzes C# code, works with the syntax tree, and gets the semantic model.

Consumer Project settings (Mapping.Consumer)

1
2
3
4
5
<ItemGroup>
    <ProjectReference Include="..\Mapping.SourceGenerators\Mapping.SourceGenerators.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false"/>
</ItemGroup>

What these two parameters do:

  • OutputItemType="Analyzer" tells MSBuild the project is a code analyzer. That's what plugs the generator into the compilation process and gets it handled correctly.
  • ReferenceOutputAssembly="false" keeps the generator assembly out of the resulting project. The generator is only needed during compilation, and an extra assembly in the references can cause type conflicts.

Implementation of Source Generator


IIncrementalGenerator vs ISourceGenerator

In the example, we use IIncrementalGenerator instead of the older ISourceGenerator. An incremental generator processes only modified files, caches results between builds, and can run in parallel. It also has a clearer API and better control over its life cycle, so it runs faster and uses less memory.

Detailed analysis of the code

Basic structure and attributes

1
2
3
4
5
6
7
[Generator]
public class MappingSourceGenerator : IIncrementalGenerator
{
    // Constants for configuration
    private const string Namespace = "Generators";
    private const string AttributeName = "MapFromAttribute";
}

The Generator attribute marks the class as a Source Generator for the compiler. The class itself implements IIncrementalGenerator rather than ISourceGenerator because it's faster.

Attribute generation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private const string AttributeSourceCode = $@"
namespace {Namespace}
{{
    [System.AttributeUsage(System.AttributeTargets.Class)]
    public class {AttributeName} : System.Attribute
    {{
        public System.Type SourceType {{ get; }}

        public {AttributeName}(System.Type sourceType)
        {{
            SourceType = sourceType;
        }}
    }}
}}";

This code generates an attribute that will be used to mark classes that require mapping.

Generator initialization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    // We register the attribute
    context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
        $"{AttributeName}.g.cs",
        SourceText.From(AttributeSourceCode, Encoding.UTF8)));

    // We configure the processing pipeline
    var provider = context.SyntaxProvider
        .CreateSyntaxProvider(
            // Quick check: Is a node a class?
            (s, _) => s is ClassDeclarationSyntax,
            // Detailed analysis: checking attributes
            (ctx, _) => GetClassDeclarationForSourceGen(ctx))
        // Filter only classes with our attribute
        .Where(t => t.mapFromAttributeFound)
        .Select((t, _) => t.classDeclaration);

    // Register code generation
    context.RegisterSourceOutput(
        context.CompilationProvider.Combine(provider.Collect()),
        (ctx, t) => GenerateCode(ctx, t.Left, t.Right));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static (ClassDeclarationSyntax classDeclaration, bool mapFromAttributeFound)
    GetClassDeclarationForSourceGen(GeneratorSyntaxContext context)
{
    // We get the syntactic tree of the class
    var classDeclarationSyntax = (ClassDeclarationSyntax)context.Node;

    // We go through all the attributes of the class
    foreach (var attributeSyntax in classDeclarationSyntax.AttributeLists
        .SelectMany(syntax => syntax.Attributes))
    {
        // We get information about the attribute symbol
        if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol 
            is not IMethodSymbol attributeSymbol)
        {
            continue;
        }

        // We check whether this is our attribute
        string attributeName = attributeSymbol.ContainingType.ToDisplayString();
        if (attributeName == $"{Namespace}.{AttributeName}")
            return (classDeclarationSyntax, true);
    }

    return (classDeclarationSyntax, false);
}

Generating the mapper code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static void GenerateCode(SourceProductionContext context,
    Compilation compilation,
    ImmutableArray<ClassDeclarationSyntax> classDeclarations)
{
    foreach (var classDeclaration in classDeclarations)
    {
        // We get a semantic model
        var semanticModel = compilation.GetSemanticModel(classDeclaration.SyntaxTree);
        var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration) as INamedTypeSymbol;

        if (classSymbol == null) continue;

        // We find the attribute and get the source type
        var attribute = classSymbol.GetAttributes()
            .FirstOrDefault(a => a.AttributeClass?.Name == AttributeName);

        if (attribute?.ConstructorArguments[0].Value is not INamedTypeSymbol sourceType) 
            continue;

        // We generate the mapper code
        string mappingCode = GenerateMappingCode(sourceType, classSymbol);
        context.AddSource(
            $"{sourceType.Name}To{classSymbol.Name}Mapper.g.cs",
            SourceText.From(mappingCode, Encoding.UTF8));
    }
}

Generation of mapping logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private static string GenerateMappingCode(
  INamedTypeSymbol sourceType, 
  INamedTypeSymbol targetType
)
{
    // We get all public properties
    var sourceProperties = sourceType.GetMembers()
        .OfType<IPropertySymbol>()
        .Where(p => p.DeclaredAccessibility == Accessibility.Public)
        .ToList();

    var targetProperties = targetType.GetMembers()
        .OfType<IPropertySymbol>()
        .Where(p => p.DeclaredAccessibility == Accessibility.Public)
        .ToList();

    // We generate the property mapping code
    var propertyMappings = new StringBuilder();
    foreach (var sourceProp in sourceProperties)
    {
        // We are looking for a suitable property by name and type
        var targetProp = targetProperties.FirstOrDefault(x =>
            x.Name == sourceProp.Name &&
            SymbolEqualityComparer.Default.Equals(x.Type, sourceProp.Type));

        if (targetProp != null)
        {
            propertyMappings.AppendLine(
                $"target.{targetProp.Name} = source.{sourceProp.Name};");
        }
    }

    // We generate the final mapper code
    return $@"
using System;

namespace {Namespace}
{{
    public static class {sourceType.Name}Extensions
    {{
        public static {targetType.Name} MapTo{targetType.Name}(
            this {sourceType.Name} source)
        {{
            if (source == null)
            {{    
                throw new ArgumentNullException(nameof(source));
            }}

            var target = new {targetType.Name}();
            {propertyMappings}
            return target;
        }}
    }}
}}";
}

Usage


To use the generator, create the two classes you want to map between and call the extension method. Its name is generated from the pattern MapTo{targetType.Name}:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Data model
public class UserDto
{
    public int Id { get; set; }
    
    public string Name { get; set; }

    public string Surname { get; set; }
}

// A target model with an attribute to generate the mapper
[MapFrom(typeof(UserDto))]
public class UserViewModel
{
    public int Id { get; set; }

    public string Name { get; set; }

    public string Surname { get; set; }
}

// Using generated code
var dto = new UserDto { Id = 1, Name = "Taras", Surname = "Kovalenko" };
var viewModel = dto.MapToUserViewModel();

If everything is set up correctly, after you build the solution you'll see the generated mapper code, plus the attribute the generator uses to find models.

sg-output

Advantages of using Source Generators

The code is generated once, at compile time, so there's no runtime overhead and no reflection delays. Type errors show up at compile time, IntelliSense fully sees the generated methods, and refactoring works the same as with any other code.

You can open the generated code and debug it like anything else. The generator is easy to extend, and the result is easier to test than reflection-based code.

Conclusion


Source Generators move routine work from runtime to compile time. The price is a separate generator project and some time spent learning the Roslyn API. In return you get fast code without reflection, type checks at build time, proper IDE support, and a generator you can extend for your own needs.

Compared to T4, PostSharp or reflection, Source Generators are faster and safer, and they're no harder to work with.

Download .md LLM corpus

Track .NET Runtime & Performance

Step 1 of 6. From code generation and collections to GC, async, cancellation, and the CLR/JIT runtime.