Leveraging Code Generation with .NET 5 to implement a builder pattern.

As a developer, you will rapidly find the need to start introducing patterns to make your code more intuitive to use, easy to read, and structured in a way that makes it harder for developers using your code to create errors.


This is especially true when modelling a public API for your objects. Two of the design patterns you should consider implementing are based on ensuring your objects are valid before they are used and to make these objects immutable to prevent changes after they are constructed. The most common way of accomplishing this is by initializing the object from the constructor and setting all properties to read-only.


For example, let's create a simple class to register a user. To create a user, let's assume Username and password are required and date of birth is optional. This can be achieved as follows.


public class NewUser
{
        public string UserName { get; private set; }
        public string Password { get; private set; }
        public DateTime? DateOfBirth { get; private set; }
	public NewUser(string userName, string password, DateTime? dateOfBirth = default)
        {
            if (userName == null)
                throw new ArgumentNullException(nameof(userName));
            if (password == null)
                throw new ArgumentNullException(nameof(password));
            UserName = userName;
            Password = password;
            DateOfBirth = dateOfBirth;
        }
}

Creating a user is as simple as


var newUser = new NewUser("Gandalf", "P@551w0rd", new DateTime(1979,5,10));

On the surface, this is a reasonable pattern. The class is immutable as once its constructed, it is read-only and we are also protecting the two mandatory parameters with validation clauses. Unfortunately, there are a few downsides, namely:


Reading the object creation line doesn’t tell us what each parameter is. We have to either go to the class structure or leverage IntelliSense to figure out what the actual values correspond to.

  • In debugging and logging, we will only know about the first validation error.

  • There’s a lot of boilerplate code involved in writing the class.

  • The order of parameters needs to be maintained to ensure that the existing code doesn’t map arguments incorrectly.

  • If we introduce a new optional parameter, anyone using reflection may experience errors if they were looking for a constructor with a specific signature.

As the number of fields on the object grows, the constructor initialization becomes even more difficult to read. You could improve readability by using parameter names explicitly when constructing the object for example:


new NewUser(userName: "Gandalf", password: "P@551w0rd", dateOfBirth: new DateTime(1979,5,10));

The problem is that at some point, you’ll find this is still awkward and error-prone. This is where the builder pattern comes in handy.


To solve these issues, we’re going to construct an object whose sole purpose is to construct our NewUser. It’s modelled to make it easy to use, enforce all rules, and make refactoring safe. A typical class with a builder may look like this:


public class NewUser
{
  public static NewUserBuilder Builder => new NewUserBuilder();
  public NewUser(string userName, string lastName, DateTime? dateOfBirth = default)
  {
    if (userName == null) throw new ArgumentNullException(nameof(userName));
    if (password == null) throw new ArgumentNullException(nameof(password));
    UserName = userName;
    Password = password;
    DateOfBirth = dateOfBirth;
  }
  public string UserName { get; private set; }
  public string Password { get; private set; }
  public DateTime? DateOfBirth { get; private set; }
 
  public class NewUserBuilder
  {
    private string _userName;
    private string _password;
    private DateTime? _dateOfBirth;
    public NewUserBuilder UserName(string userName)
    {
      _userName = userName;
      return this;
    }
    public NewUserBuilder Password(string password)
    {
      _password = password;
      return this;
    }
    public NewUserBuilder DateOfBirth(DateTime? dateOfBirth)
    {
      _dateOfBirth = dateOfBirth;
      return this;
    }
    public NewUserBuilder DateOfBirth(int year, int month, int day)
    {
      _dateOfBirth = new DateTime(year, month, day);
      return this;
    }
    public NewUser Build()
    {
      Validate();
      return new NewUser(_userName, _password, _dateOfBirth);
    }
    public void Validate()
    {
      void AddError(Dictionary<string, string> items, string property, string message)
      {
        if (items.TryGetValue(property, out var errors))
          items[property] = $"{ errors}\n{ message}";
        else
          items[property] = message;
      }
      Dictionary<string, string> errors = new Dictionary<string, string>();
      if (_userName == null) AddError("UserName", _userName);
      if (_password == null) AddError("Password", _userName);
      if (errors.Count > 0)
        throw new BuilderException(typeof(NewUser), errors);
    }
  }
}

This means that to create a NewUser object, it's now as simple as:



var newUser = NewUser.Builder.UserName("Gandalf").Password("P@551w0rd").DateOfBirth(1979,5,10).Build();

This creates a clean API for constructing our NewUser object. Its neat and all the rules are enforced and we can pass it between methods and literally “build-it-up” so to speak. Users who are going to use our SDK will appreciate this, but look at all that extra code we have to write to make our builder method. The builder code is far bigger than the class itself!


This is the fundamental issue with the builder pattern – it’s a lot of boilerplate code to write that is not only boring but that means the process is also extremely error-prone, not to mention difficult to maintain.


Then I discovered that .NET 5 has introduced an awesome new feature to generate code based on Roslyn analyzers, which is currently in preview. The idea is that you can auto-generate additional code to include in your project at compile time. This code normally doesn’t get added to your project as user code – it’s dynamically added to your project when it is sent to the compiler but in my case, I wanted to save it locally. If you want to revert this behaviour, you can uncomment this line.



//context.AddSource( $"{classBuilder.Key}.Builder.cs", SourceText.From(classBuilder.Value, Encoding.UTF8));

So that leads me to think of a way to design a build process that leverages these capabilities in my current build processes. To do this, you will need the latest version of visual studio and the newly released .NET 5 SDK.


So how do we leverage this in our current projects and save an incredible amount of time?


You will need to create 2 .NET Standard projects. The first is simply a common library containing the GenerateBuilderAttribute and the other my custom BuilderException classes.


public class GenerateBuilderAttribute : Attribute
{
        
}

public class BuilderException : Exception
{
public BuilderException(Dictionary<string, string> errors) : this(null, errors)
      {
                
      }
      public BuilderException(string message, Dictionary<string,string> errors) : base(GetErrorMessage(message,errors))
      {
     Errors = errors;
      }


	private static string GetErrorMessage(string message, Dictionary<string, string> errors)
	{
            if (message != null)
                return message;
            var sb = new StringBuilder();
            sb.AppendLine("Error building object. The following properties have errors:");
            foreach (var error in errors)
            {
                sb.AppendLine($"   {error.Key}: {error.Value}");
            }


            return sb.ToString();
	}


        public Dictionary<string,string> Errors { get; } = new Dictionary<string, string>();
}

Now create another .net standard project and then add nuget references to Microsoft.CodeAnalysis.Analyzers. I'm using version 3.3.1


Microsoft.CodeAnalysis.CSharp. I'm using version 3.8.0-5.final


Microsoft.CodeAnalysis.CSharp.Workspaces. I'm using version 3.8.0-5.final


Ensure your LangVersion is set to latest.


In this project, create a simple helper method to determine which classes have this [GenerateBuilder] attribute and to help us navigate the class. To do this, I created the following extension methods:


public static class Extensions
    {
        public static bool HasAttribute(this SyntaxList<AttributeListSyntax> attributes, string name)
        {
            string fullname, shortname;
            var attrLen = "Attribute".Length;
            if (name.EndsWith("Attribute"))
            {
                fullname = name;
                shortname = name.Remove(name.Length - attrLen, attrLen);
            }
            else
            {
                fullname = name + "Attribute";
                shortname = name;
            }


            return attributes.Any(al => al.Attributes.Any(a => a.Name.ToString() == shortname || a.Name.ToString() == fullname));
        }
        
        public static T FindParent<T>(this SyntaxNode node) where T : class
        {
            var current = node;
            while(true)
            {
                current = current.Parent;
                if (current == null || current is T)
                    return current as T;
            }
        }
    }

Then we get to the magic part namely C# Source Generators.


By leveraging a new C# compiler feature that lets C# developers inspect user code and generate new C# source files that can be added to a compilation, I then created a builder source generator to generate my code based on my models in my project as defined below.


[Generator]
    public class BuilderSourceGenerator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            var projectpath =
                ((Microsoft.CodeAnalysis.SourceFileResolver)((Microsoft.CodeAnalysis.CSharp.CSharpCompilation)context.Compilation).Options
                    .SourceReferenceResolver).BaseDirectory + "\\Models\\";


            // using the context, get a list of syntax trees in the users compilation
            foreach (var syntaxTree in context.Compilation.SyntaxTrees)
            {
                var classBuilders = GenerateBuilder(syntaxTree);


                foreach (var classBuilder in classBuilders)
                {
                    var sourceCode = SourceText.From(classBuilder.Value, Encoding.UTF8).ToString();
                    var fileName = projectpath + classBuilder.Key + ".Builder.cs";
                    File.WriteAllText(fileName, sourceCode);


                    //context.AddSource( $"{classBuilder.Key}.Builder.cs", SourceText.From(classBuilder.Value, Encoding.UTF8));
                }


            }
        }
        public void Initialize(GeneratorInitializationContext context)
        {
            // Attach the debugger if required.
            //if (!Debugger.IsAttached)
            //{
            //    Debugger.Launch();
            //}
        }


        public static Dictionary<string, string> GenerateBuilder(SyntaxTree syntaxTree)
        {
            var builderTemplate = @"
using System;
using System.Collections.Generic;
@usings


namespace @namespace
{
    partial class @className
    {
        @constructor
        public static @builderName Builder => new @builderName();
        public class @builderName
        {
            @builderMethods
            public @className Build()
            {
                Validate();
                return new @className
                {
                    @propertiesCopy
                };
            }
            public void Validate()
            {
                void AddError(Dictionary<string, string> items, string property, string message)
                {
                    if (items.TryGetValue(property, out var error))
                        items[property] = $""{error}\n{message}"";
                    else
                        items[property] = message;
                }
                Dictionary<string,string> errors = new Dictionary<string, string>();
                @validations
                if(errors.Count > 0)
                    throw new BuilderCommon.BuilderException(errors);
            }
        }
    }
}";
            var builderMethodsTemplate = @"
            private @propertyType @backingField;
            public @builderName @propertyName(@propertyType @propertyName)
            {
                @backingField = @propertyName;
                return this;
            }
            ";


            var classToBuilder = new Dictionary<string, string>();
            
            var root = syntaxTree.GetRoot();
            var usings = (root as CompilationUnitSyntax).Usings.ToString();
            var classesWithAttribute = root
                .DescendantNodes()
                .OfType<ClassDeclarationSyntax>()
                .Where(cds => cds.AttributeLists.HasAttribute(nameof(BuilderCommon.GenerateBuilderAttribute)))
                .ToList();


            foreach (var classDeclaration in classesWithAttribute)
            {
                var sb = new StringBuilder();
                var namespaceName = classDeclaration.FindParent<NamespaceDeclarationSyntax>().Name.ToString();
                var className = classDeclaration.Identifier.Text;
                var properties = classDeclaration.DescendantNodes().OfType<PropertyDeclarationSyntax>().ToList();
                var builderName = $"{className}Builder";
                var hasDefaultConstructor = classDeclaration
                    .DescendantNodes()
                    .OfType<ConstructorDeclarationSyntax>()
                    .Any(x => x.ParameterList.Parameters.Count == 0);
                var defaultConstructorBody = hasDefaultConstructor ? string.Empty : $"private {className}(){{}}";
                var builderMethods = new StringBuilder();
                foreach (var property in properties.Where(x => x.Modifiers.All(m => m.ToString() != "static")).Select(x => new BuilderPropertyInfo(x)))
                {
                    builderMethods.AppendLine(builderMethodsTemplate
                        .Replace("@builderName", builderName)
                        .Replace("@backingField", property.BackingFieldName)
                        .Replace("@propertyName", property.Name)
                        .Replace("@propertyType", property.Type));
                }


                var propertiesCopy = new StringBuilder();


                foreach (var property in properties.Select(x => new BuilderPropertyInfo(x)))
                {
                    
                    propertiesCopy.AppendLine($"{property.Name} = {property.BackingFieldName},");
                }
                
                
                var validations = new StringBuilder();
                var requiredProperties = properties.Where(p => p.AttributeLists.HasAttribute("Required"));
                foreach (var property in requiredProperties.Select(x => new BuilderPropertyInfo(x)))
                {
                    validations.AppendLine($@"if({property.BackingFieldName} == default)  AddError(errors, ""{property.Name}"", ""Value is required"");");
                }


                sb.AppendLine(builderTemplate
                    .Replace("@namespace", namespaceName)
                    .Replace("@className", className)
                    .Replace("@constructor", defaultConstructorBody)
                    .Replace("@builderName", builderName)
                    .Replace("@usings", usings)
                    .Replace("@validations", validations.ToString())
                    .Replace("@builderMethods", builderMethods.ToString())
                    .Replace("@propertiesCopy", propertiesCopy.ToString()));
                
                classToBuilder[className] = sb.ToString();
            }
        


            return classToBuilder;
        }


        struct BuilderPropertyInfo
        {
            public BuilderPropertyInfo(PropertyDeclarationSyntax property) : this()
            {
                Type = property.Type.ToString();
                Name = property.Identifier.ToString();
                ParameterName = $"{Name[0].ToString().ToLower()}{Name.Remove(0, 1)}";
                BackingFieldName = $"_{ParameterName}";
            }


            public string Name { get; set; }
            public string Type { get; set; }
            public string ParameterName { get; set; }
            public string BackingFieldName { get; set; }
        }
    }

Now to demonstrate this, create a new .Net console app but make sure the Target Framework is .net5.0. Reference the two .NET Standard projects you created earlier but for the Builder project, edit your project file and add ReferenceOutputAssembly="false" OutputItemType="Analyzer" to look like this.


<ProjectReference Include="..\BuilderGenerator\BuilderGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

Now in a folder called Models add our Model as follows


[GenerateBuilder]
public partial class Person
{
        [Required]
        public string FirstName { get; private set; }
        [Required]
        public string LastName { get; private set; }
        public DateTime? BirthDate { get; private set; }
}

Now simply build your solution. A file called Person.Builder.cs will be added, providing all your Builder logic.


partial class Person
    {
        private Person(){}
        public static PersonBuilder Builder => new PersonBuilder();
        public class PersonBuilder
        {
            
            private string _firstName;
            public PersonBuilder FirstName(string FirstName)
            {
                _firstName = FirstName;
                return this;
            }
            


            private string _lastName;
            public PersonBuilder LastName(string LastName)
            {
                _lastName = LastName;
                return this;
            }
            


            private DateTime? _birthDate;
            public PersonBuilder BirthDate(DateTime? BirthDate)
            {
                _birthDate = BirthDate;
                return this;
            }
            


            public Person Build()
            {
                Validate();
                return new Person
                {
                    FirstName = _firstName,
LastName = _lastName,
BirthDate = _birthDate,


                };
            }
            public void Validate()
            {
                void AddError(Dictionary<string, string> items, string property, string message)
                {
                    if (items.TryGetValue(property, out var error))
                        items[property] = $"{error}\n{message}";
                    else
                        items[property] = message;
                }
                Dictionary<string,string> errors = new Dictionary<string, string>();
                if(_firstName == default)  AddError(errors, "FirstName", "Value is required");
if(_lastName == default)  AddError(errors, "LastName", "Value is required");


                if(errors.Count > 0)
                    throw new BuilderCommon.BuilderException(errors);
            }
        }
    }

Now creating a person is as simple as:


var person = Person.Builder
                .FirstName("James")
                .LastName("Melvin")
                .Build();

Happy coding. The full source code is available on GitHub.