Implement Builders easily with Source Generator in .NET

I created a YouTube video on Source Generator in which I showcased one possible implementation. However, I feel that I didn’t fully highlight its capabilities. In this blog post, I aim to demonstrate how to use the Source Generator to automatically create builders for a given class.

Check the YouTube video and the source code here

For our Builder Generator implementation, let’s start from scratch. Create a new class library project, name it SourceGenerator, add the necessary NuGet package references, and add a new class called AutoBuilderGenerator as shown in the code snippet below

SourceGenerator.csproj

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <LangVersion>latest</LangVersion>
        <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    </PropertyGroup>

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

</Project>

The EnforceExtendedAnalyzerRules is required to remove the warning that appears when adding the Generator attribute.

AutoBuilderGenerator class

namespace SourceGenerator;

[Generator]
public class AutoBuilderGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // implementation goes here
    }
}

You need to generate a provider instance. This instance uses the context to filter and return only the syntax related to the class definition that has an AutoBuilder attribute:

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    var provider = context.SyntaxProvider.CreateSyntaxProvider(
        (node, _) => node is ClassDeclarationSyntax t && t.AttributeLists.Any(x => x.Attributes.Any(a => a.Name.ToString() == "AutoBuilder")),
        (syntaxContext, _) => (ClassDeclarationSyntax)syntaxContext.Node
    ).Where(x => x is not null);

    var compilation = context.CompilationProvider
                             .Combine(provider.Collect());
    
    context.RegisterSourceOutput(compilation, Execute);
}

And now go ahead and create the Execute method:

private void Execute(SourceProductionContext context, (Compilation compilation, ImmutableArray<ClassDeclarationSyntax> classes) tuple)
{
  var (compilation, classes) = tuple;
  
  foreach (var syntax in classes)
  {
    // per class implementation
    var symbol = compilation.GetSemanticModel(syntax.SyntaxTree)
                                    .GetDeclaredSymbol(syntax) as INamedTypeSymbol;
  }
}

Since we already know that the class has the AutoBuilder attribute, we can start gathering all the necessary information to construct our generated code.

First, to get the class namespace we can use the following code:

// get the namespace of the current syntax
var syntaxParent = syntax.Parent;
string @namespace = string.Empty;
if (syntaxParent is BaseNamespaceDeclarationSyntax namespaceDeclaration)
{
    @namespace = namespaceDeclaration.Name.ToString();
}

Now, let’s generate our boilerplate code. I’m going to use a StringBuilder to build the class code string.

Note that I’m adding a static implicit operator here. This will be useful when I want to implicitly convert the builder into the desired object.

string prefixCode = $$"""
              // <auto-generated />
              namespace {{@namespace}};
              
              public class {{symbol!.Name}}Builder
              {
                protected {{symbol!.Name}} {{symbol!.Name}} = new {{symbol!.Name}}();
                public static implicit operator {{symbol!.Name}}({{symbol!.Name}}Builder builder)
                {
                    return builder.{{symbol!.Name}};
                }
              """;

string suffixCode = """
              }
              """;

StringBuilder codeBuilder = new StringBuilder();

codeBuilder.AppendLine(prefixCode);

// append code for every properties

codeBuilder.AppendLine(suffixCode);

Now, let’s identify all the properties that have a setter so we can create a WithMethod for each property:

replace // append code for every properties with the following

var properties = symbol!.GetMembers()
                                    .OfType<IPropertySymbol>()
                                    .Where(x => x.SetMethod is not null);
                                    
foreach (var property in properties)
{
  codeBuilder.AppendLine($@"    public {symbol!.Name}Builder With{property.Name}({property.Type} {property.Name.ToLower()})");
  codeBuilder.AppendLine("    {");
  codeBuilder.AppendLine($@"        {symbol!.Name}.{property.Name} = {property.Name.ToLower()};");
  codeBuilder.AppendLine("        return this;");
  codeBuilder.AppendLine("    }");
  codeBuilder.AppendLine();
}

We can go a step further by ignoring any property with the AutoBuilderIgnore attribute using the following code:

foreach (var property in properties)
{
  // check if property has AutoBuilderIgnore as attribute
  var ignoreAttribute = property.GetAttributes()
                             .Any(x => x.AttributeClass?.Name == "AutoBuilderIgnoreAttribute");
  
  if (ignoreAttribute)
  {
      continue;
  }
  
  // rest of the properties code here
}

If we have a property with a List, we might want to generate a method that adds one item instead of a list. I’m using params here so we can have multiple items, if needed, as parameters.

if (property.Type.ToString().StartsWith("System.Collections.Generic.List<"))
{
    var listType = property.Type.ToString().Replace("System.Collections.Generic.List<", "").Replace(">", "");
    codeBuilder.AppendLine($@"    public {symbol!.Name}Builder Add{property.Name}(params {listType}[] {property.Name.ToLower()})");
    codeBuilder.AppendLine("    {");
    codeBuilder.AppendLine($@"        {symbol!.Name}.{property.Name}.AddRange({property.Name.ToLower()}.ToList());");
    codeBuilder.AppendLine("        return this;");
    codeBuilder.AppendLine("    }");
    codeBuilder.AppendLine();
}

and now register the code in the context and let us try everything

// end of foreach loop

codeBuilder.AppendLine(suffixCode);
            
context.AddSource($"{symbol!.Name}Builder.g.cs", codeBuilder.ToString());

To test this out, create a new project, add the SourceGenerator reference, and ensure to specify the OutputItemType as Analyzer.

Client.csproj:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
    </PropertyGroup>

    <ItemGroup>
      <ProjectReference Include="..\SourceGenerator\SourceGenerator.csproj" OutputItemType="Analyzer"  />
    </ItemGroup>

</Project>

Now create the following attributes and a class to test the generated builder

// Attributes

public class AutoBuilderAttribute : Attribute
{
}

public class AutoBuilderIgnoreAttribute : Attribute
{
}
[AutoBuilder]
public class Person
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Address { get; set; } = string.Empty;
    
    public List<string> Orders { get; set; } = [];

    [AutoBuilderIgnore]
    public double Ignored { get; set; }
}

Now, in your Program.cs, you can start building a Person using the automatically generated PersonBuilder class.

var builder = new PersonBuilder()
              .WithId(1)
              .WithName("John")
              .WithAddress("123 Main St")
              .AddOrders("My Order", "My Second Order");

Person john = builder; // implicit operator will convert the builder into Person.

Happy Coding!

Recent Posts

HybridCache in .NET 9 is Awesome!
.NET 9 is now live, and it comes with a new set of features. Some are great, and some are just icing on the cake. …
Adding Custom Formatting to Your Classes with IFormattable
DateTime has a great feature that I often replicate in my classes: the ability for users to format the ToString output however they want. Using …
Azure-Sync: Sync your Azure App Settings to local
Azure-Sync is a handy shell script tool designed to help .NET developers working with Azure App Services. Inspired by the functionality provided by the Azure …
Secure On-Premise .NET Application with Azure Key Vault
Suppose you have your Web App and Database server hosted locally on your On-Premises servers. You want to use Azure Key Vault with your .NET …

Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.