A while ago I wrote a post on builder pattern in unit tests. I’m a fan of using builder classes but writing these builder classes aren’t fun if you have many classes you want to have builder for. Also, it is quite annoying to maintain these when adding new properties or removing an existing property. In the past, I used T4 text templates to automatically generate C# builder classes. With the introduction of Source Generators in .NET 5.0, I thought I’d give it a try to automatically generate builder classes.
We’re going to use Source Generators to produce a base Builder class and an attribute AutoGenerateBuilder
. The attribute will be used to mark which classes the builder will be produced for. We then look for all the classes that has this attribute. Next, iterate through all properties to create the backing fields and With
methods.
1. Let’s create a .Net Standard 2.0 library project and name it NetStandardLibrary.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" />
<ItemGroup/>
</Project>
2. Create a class called AutoGenerateBuilderSyntaxReceiver
that implements the ISyntaxReceiver
. This class allows us to filter out only the classes with AutoGenerateBuilder
attribute.
internal class AutoGenerateBuilderSyntaxReceiver : ISyntaxReceiver
{
public IList<ClassDeclarationSyntax> CandidateClasses { get; } = new List<ClassDeclarationSyntax>();
/// <summary>
/// Called for every syntax node in the compilation, we can inspect the nodes and save any information useful for generation
/// </summary>
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// any field with at least AutoGenerateBuilder attribute is a candidate for property generation
if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&
classDeclarationSyntax.AttributeLists.Any(x => x.Attributes.Any(a => a.Name.ToString().Equals("GeneratedClassBuilders.AutoGenerateBuilder"))))
{
CandidateClasses.Add(classDeclarationSyntax);
}
}
}
3. Next, we need a class implementing the ISourceGenerator
interface with a Generator
attribute.
[Generator]
public class BuilderGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
throw new NotImplementedException();
}
public void Execute(GeneratorExecutionContext context)
{
throw new NotImplementedException();
}
}
We’re required to implement two methods, Execute
and Initialize
. The Execute
is the where the compiler calls to start the generation process and we will have the source generator logic there. The Initialize method allows us to register our AutoGenerateBuilderSyntaxReceiver
.
4. Register AutoGenerateBuilderSyntaxReceiver
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new AutoGenerateBuilderSyntaxReceiver());
}
5. Generate the AutoGenerateBuilder
attribute and base Builder
class
private static void InjectBaseBuilderClass(GeneratorExecutionContext context)
{
const string baseBuilderText = @"
using System;
using System.Collections.Generic;
using System.Text;
namespace GeneratedClassBuilders
{
public abstract class Builder<T> where T : class
{
protected Lazy<T> Object;
public abstract T Build();
public Builder<T> WithObject(T value)
=> WithObject(() => value);
public Builder<T> WithObject(Func<T> func)
{
Object = new Lazy<T>(func);
return this;
}
protected virtual void PostBuild(T value) {}
}
}";
context.AddSource("BaseBuilder.cs", SourceText.From(baseBuilderText, Encoding.UTF8));
}
private static void InjectAutoGenerateBuilderAttribute(GeneratorExecutionContext context)
{
const string attributeText = @"
using System;
namespace GeneratedClassBuilders
{
[AttributeUsage(AttributeTargets.Class)]
sealed class AutoGenerateBuilderAttribute : Attribute
{
public AutoGenerateBuilderAttribute() {}
}
}
";
context.AddSource("AutoGenerateBuilderAttribute.cs", SourceText.From(attributeText, Encoding.UTF8));
}
6. We need to find the type of each class filtered by AutoGenerateBuilderSyntaxReceiver
private static List<INamedTypeSymbol> GetClassSymbols(GeneratorExecutionContext context, AutoGenerateBuilderSyntaxReceiver receiver)
{
var classSymbols = new List<INamedTypeSymbol>();
foreach (var candidateClass in receiver.CandidateClasses)
{
var namespaceName = GetNamespaceFrom(candidateClass);
var fullClassName = string.IsNullOrWhiteSpace(namespaceName)
? candidateClass.Identifier.ToString()
: $"{namespaceName}.{candidateClass.Identifier}";
var classSymbol = context.Compilation.GetTypeByMetadataName(fullClassName);
if (classSymbol != null)
classSymbols.Add(classSymbol);
}
return classSymbols;
}
public static string GetNamespaceFrom(SyntaxNode s) =>
s.Parent switch
{
NamespaceDeclarationSyntax namespaceDeclarationSyntax => namespaceDeclarationSyntax.Name.ToString(),
null => string.Empty,
_ => GetNamespaceFrom(s.Parent)
};
7. We need to find all the properties from INamedTypeSymbol
private static IEnumerable<IPropertySymbol> GetProperties(INamedTypeSymbol classSymbol)
{
var properties = classSymbol.GetMembers().OfType<IPropertySymbol>()
.Where(x => x.SetMethod is not null)
.Where(x => x.CanBeReferencedByName).ToList();
var propertyNames = properties.Select(x => x.Name);
var baseType = classSymbol.BaseType;
while (baseType != null)
{
properties.AddRange(baseType.GetMembers().OfType<IPropertySymbol>()
.Where(x => x.CanBeReferencedByName)
.Where(x => x.SetMethod is not null)
.Where(x => !propertyNames.Contains(x.Name)));
baseType = baseType.BaseType;
}
return properties;
}
8. Generate builder source code for each class
private string CreateBuilderCode(INamedTypeSymbol classSymbol)
=> $@"
using System;
using GeneratedClassBuilders;
using {classSymbol.ContainingNamespace};
namespace GeneratedClassBuilders
{{
public partial class {classSymbol.Name}Builder : Builder<{classSymbol.Name}>
{{
{GeneratePropertiesCode(classSymbol)}
{GenerateBuildsCode(classSymbol)}
}}
}}";
private static string GeneratePropertiesCode(INamedTypeSymbol classSymbol)
{
var properties = GetProperties(classSymbol);
var output = new StringBuilder();
foreach (var property in properties)
{
output.AppendLine($@"
private Lazy<{property.Type}> _{CamelCase(property.Name)} = new Lazy<{property.Type}>(default({property.Type}));
public {classSymbol.Name}Builder With{property.Name}({property.Type} value)
=> With{property.Name}(() => value);
public {classSymbol.Name}Builder With{property.Name}(Func<{property.Type}> func)
{{
_{CamelCase(property.Name)} = new Lazy<{property.Type}>(func);
return this;
}}
public {classSymbol.Name}Builder Without{property.Name}()
=> With{property.Name}(() => default({property.Type}));");
}
return output.ToString();
}
private static string GenerateBuildsCode(INamedTypeSymbol classSymbol)
{
var properties = GetProperties(classSymbol);
var output = new StringBuilder();
output.AppendLine($@" public override {classSymbol.Name} Build()
{{
if (Object?.IsValueCreated != true)
{{
Object = new Lazy<{classSymbol.Name}>(new {classSymbol.Name}
{{");
foreach (var property in properties)
output.AppendLine($@" {property.Name} = _{CamelCase(property.Name)}.Value,");
output.AppendLine($@"
}});
}}
PostBuild(Object.Value);
return Object.Value;
}}
public static {classSymbol.Name} Default()
=> new {classSymbol.Name}();");
return output.ToString();
}
private static string CamelCase(string value)
=> $"{value.Substring(0, 1).ToLowerInvariant()}{value.Substring(1)}";
9. Putting everything together in the Execute
method
public void Execute(GeneratorExecutionContext context)
{
InjectAutoGenerateBuilderAttribute(context);
InjectBaseBuilderClass(context);
if (context.SyntaxReceiver is not AutoGenerateBuilderSyntaxReceiver receiver)
return;
var classSymbols = GetClassSymbols(context, receiver);
foreach (var classSymbol in classSymbols)
{
context.AddSource($"{classSymbol.Name}_Builder.cs", SourceText.From(CreateBuilderCode(classSymbol), Encoding.UTF8));
}
}
10. In the project that you want to use the generator, reference the generator project as followed
<ItemGroup>
<ProjectReference Include="..\NetStandardLibrary\NetStandardLibrary.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
That’s it. In the console project that uses the generator, I add a class that looks like this
[GeneratedClassBuilders.AutoGenerateBuilder]
public class UserDto
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
public int Credits { get; set; }
public IEnumerable<string> Roles { get; set; }
public EmailDto PrimaryEmail { get; set; }
public IEnumerable<EmailDto> SecondaryEmails { get; set; }
public DateTime? QuitDate { get; set; }
}
public class EmailDto
{
public string Value { get; set; }
}
After I build the project, I’m able to use the generated builder.
static void Main(string[] args)
{
var user = new GeneratedClassBuilders.UserDtoBuilder()
.WithFirstName("Tom")
.WithLastName("Phan")
.Build();
Console.WriteLine($"{user.FirstName} {user.LastName}");
Console.ReadLine();
}
And if you right click and ‘Go to implementation’, this is what the builder looks like
It was fun trying out Source Generators. However, the experience was not great. These are some of the issues I had.
- Visual Studio does not recognise the generated code quite often. Also, if you add the attribute to a new class, the intellisense does not always pick it up right away. Restarting Visual Studio solves the problem but that is annoying.
- Debugging is painful. Unlike T4 text templates, the generated source code is not dumped into a file so it makes it harder to debug. You can add
Debugger.Launch()
in the generator and this requires Just In Time (JIT) debugger to be enabled. My Visual Studio slows down a lot and crashes a few times after I enable JIT debugger. - There are not a lot of documentation. I base my implementation on the Source Generators Cookbook and Source Generators samples with a lot of trials and errors.
This is the full source code of the generator.
[Generator]
public class BuilderGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new AutoGenerateBuilderSyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
InjectAutoGenerateBuilderAttribute(context);
InjectBaseBuilderClass(context);
if (context.SyntaxReceiver is not AutoGenerateBuilderSyntaxReceiver receiver)
return;
var classSymbols = GetClassSymbols(context, receiver);
foreach (var classSymbol in classSymbols)
{
context.AddSource($"{classSymbol.Name}_Builder.cs", SourceText.From(CreateBuilderCode(classSymbol), Encoding.UTF8));
}
}
private static void InjectBaseBuilderClass(GeneratorExecutionContext context)
{
const string baseBuilderText = @"
using System;
using System.Collections.Generic;
using System.Text;
namespace GeneratedClassBuilders
{
public abstract class Builder<T> where T : class
{
protected Lazy<T> Object;
public abstract T Build();
public Builder<T> WithObject(T value)
=> WithObject(() => value);
public Builder<T> WithObject(Func<T> func)
{
Object = new Lazy<T>(func);
return this;
}
protected virtual void PostBuild(T value) {} }
}";
context.AddSource("BaseBuilder.cs", SourceText.From(baseBuilderText, Encoding.UTF8));
}
private static void InjectAutoGenerateBuilderAttribute(GeneratorExecutionContext context)
{
const string attributeText = @"
using System;
namespace GeneratedClassBuilders
{
[AttributeUsage(AttributeTargets.Class)]
sealed class AutoGenerateBuilderAttribute : Attribute
{
public AutoGenerateBuilderAttribute() {}
}
}
";
context.AddSource("AutoGenerateBuilderAttribute.cs", SourceText.From(attributeText, Encoding.UTF8));
}
private static List<INamedTypeSymbol> GetClassSymbols(GeneratorExecutionContext context, AutoGenerateBuilderSyntaxReceiver receiver)
{
var classSymbols = new List<INamedTypeSymbol>();
foreach (var candidateClass in receiver.CandidateClasses)
{
var namespaceName = GetNamespaceFrom(candidateClass);
var fullClassName = string.IsNullOrWhiteSpace(namespaceName)
? candidateClass.Identifier.ToString()
: $"{namespaceName}.{candidateClass.Identifier}";
var classSymbol = context.Compilation.GetTypeByMetadataName(fullClassName);
if (classSymbol != null)
classSymbols.Add(classSymbol);
}
return classSymbols;
}
public static string GetNamespaceFrom(SyntaxNode s) =>
s.Parent switch
{
NamespaceDeclarationSyntax namespaceDeclarationSyntax => namespaceDeclarationSyntax.Name.ToString(),
null => string.Empty,
_ => GetNamespaceFrom(s.Parent)
};
private static IEnumerable<IPropertySymbol> GetProperties(INamedTypeSymbol classSymbol)
{
var properties = classSymbol.GetMembers().OfType<IPropertySymbol>()
.Where(x => x.SetMethod is not null)
.Where(x => x.CanBeReferencedByName).ToList();
var propertyNames = properties.Select(x => x.Name);
var baseType = classSymbol.BaseType;
while (baseType != null)
{
properties.AddRange(baseType.GetMembers().OfType<IPropertySymbol>()
.Where(x => x.CanBeReferencedByName)
.Where(x => x.SetMethod is not null)
.Where(x => !propertyNames.Contains(x.Name)));
baseType = baseType.BaseType;
}
return properties;
}
private string CreateBuilderCode(INamedTypeSymbol classSymbol)
=> $@"
using System;
using GeneratedClassBuilders;
using {classSymbol.ContainingNamespace};
namespace GeneratedClassBuilders
{{
public partial class {classSymbol.Name}Builder : Builder<{classSymbol.Name}>
{{
{GeneratePropertiesCode(classSymbol)}
{GenerateBuildsCode(classSymbol)}
}}
}}";
private static string GeneratePropertiesCode(INamedTypeSymbol classSymbol)
{
var properties = GetProperties(classSymbol);
var output = new StringBuilder();
foreach (var property in properties)
{
output.AppendLine($@"
private Lazy<{property.Type}> _{CamelCase(property.Name)} = new Lazy<{property.Type}>(default({property.Type}));
public {classSymbol.Name}Builder With{property.Name}({property.Type} value)
=> With{property.Name}(() => value);
public {classSymbol.Name}Builder With{property.Name}(Func<{property.Type}> func)
{{
_{CamelCase(property.Name)} = new Lazy<{property.Type}>(func);
return this;
}}
public {classSymbol.Name}Builder Without{property.Name}()
=> With{property.Name}(() => default({property.Type}));");
}
return output.ToString();
}
private static string GenerateBuildsCode(INamedTypeSymbol classSymbol)
{
var properties = GetProperties(classSymbol);
var output = new StringBuilder();
output.AppendLine($@" public override {classSymbol.Name} Build()
{{
if (Object?.IsValueCreated != true)
{{
Object = new Lazy<{classSymbol.Name}>(new {classSymbol.Name}
{{");
foreach (var property in properties)
output.AppendLine($@" {property.Name} = _{CamelCase(property.Name)}.Value,");
output.AppendLine($@"
}});
}}
PostBuild(Object.Value);
return Object.Value;
}}
public static {classSymbol.Name} Default()
=> new {classSymbol.Name}();");
return output.ToString();
}
private static string CamelCase(string value)
=> $"{value.Substring(0, 1).ToLowerInvariant()}{value.Substring(1)}";
}