DEV Community

Guillaume Faas
Guillaume Faas

Posted on

How I Removed 1,500 Lines of Boilerplate with a Source Generator

In my previous blog post, I explained why I rely so heavily on the Builder pattern. It facilitates the creation of my requests, guiding developers when creating objects.

This wasn't just a personal preference. I ended up there because Builders ticked all the boxes:

  • Predictable object creation
  • Control over validation failure
  • Support for both mandatory and optional parameters
  • A way to guide users with a Fluent API

But implementing those builders came with a heavy cost: boilerplate code. Each builder added 50+ lines of code on average; for simple/repetitive ones, and much more for complex ones.
That doesn't scale well: multiply this by 30+ use cases, and you get a lot of code that you have to write and maintain.

Let's address that particular problem.

What do we need?

The problem is not the builder itself. It's how much (repetitive) code it takes to get there, especially when a lot of them are very similar.

What we want is a way to:

  • Declare which requests require a builder
  • Declare mandatory and optional properties
  • Add validation rules
  • Return a Result upon creation, that will represent either a Request or a failure

In short, we want the same benefits, but with zero boilerplate.

Of course, we will not be able to apply this everywhere; some builders are quite specific, with internal behaviors. The goal is to remove the basic ones, the majority of them.

What are source generators?

Source Generators let you write code that runs at compile-time and generates other code.

In our case, we can write a class to make the generator:

  • Detect specific attributes
  • Create a builder dynamically
  • Generate a .g.cs file with all the code

For us, it means fewer files to maintain, less code to write. We also keep a consistent pattern across the codebase.
Also, adding a new builder is completely transparent; all we have to do is to create a new class with the rightattributes, and "voilà".

The Basics

If you want to build something similar, here are the foundational steps you'll need to follow:

  1. Create a new Class Library project for the Generator
  2. Create a Generator class that implements IIncrementalGenerator
  3. Use Roslyn's syntax/semantic model to detect classes and their attributes
  4. Generate new .cs file using SourceProductionContext

Here's a minimal example of what the setup looks like:

[Generator] public class BuilderGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var structDeclarations = IdentifyBuilders(context); context.RegisterSourceOutput(structDeclarations, (productionContext, structSymbols) => { structSymbols.ToList().ForEach(symbol => GenerateSourceCode(symbol, productionContext)); }); } private static void GenerateSourceCode(INamedTypeSymbol structSymbol, SourceProductionContext productionContext) { var generatedCode = ... // Generate the builder's code var fileName = $"{structSymbol.Name}Builder.g.cs"; productionContext.AddSource(fileName, SourceText.From(generatedCode, Encoding.UTF8)); } } 
Enter fullscreen mode Exit fullscreen mode

We'll walk you through the building blocks in the next section.

Under the Hood

Here's how I structured the generator.

I have four attributes:

  • "Builder" which will decorate a struct, marking them as requiring builder.
  • "Mandatory" which will decorate a property, marking it as mandatory.
  • "Optional" which will decorate a property, marking it as optional.
  • "Validation" which will decorate a method, used to validate the request.

The generator will find all structs marked with Builder...

private static bool HasBuilderAttribute(SyntaxNode node) => node is StructDeclarationSyntax declaration && declaration.AttributeLists .SelectMany(syntax => syntax.Attributes) .Any(syntax => syntax.Name.ToString() == BuilderAttribute); private static IncrementalValueProvider<ImmutableArray<INamedTypeSymbol>> IdentifyBuilders( IncrementalGeneratorInitializationContext context) => context.SyntaxProvider .CreateSyntaxProvider( static (node, _) => HasBuilderAttribute(node), static (context, _) => GetStructSymbol(context) ) .Where(symbol => symbol is not null) .Select((symbol, _) => symbol) .Collect(); 
Enter fullscreen mode Exit fullscreen mode

And fetch properties marked as Mandatory or Optional.

private static IEnumerable<MandatoryProperty> GetMandatoryProperties(INamedTypeSymbol structSymbol) => GetMembersForAttribute(structSymbol, MandatoryProperty.AttributeName).Select(MandatoryProperty.FromMember); private static IEnumerable<IPropertySymbol> GetMembersForAttribute(INamedTypeSymbol structSymbol, string attributeName) => structSymbol.GetMembers().OfType<IPropertySymbol>() .Where(member => member.GetAttributes().Any(attribute => attribute.AttributeClass?.Name == attributeName)); 
Enter fullscreen mode Exit fullscreen mode

When all the data is gathered, we proceed to create our Builder, line by line:

  • Our using statements
  • The namespace
  • Interface declarations
  • Our builder
  • etc...

We're essentially creating the entire file ourselves, as a string:

public string GenerateCode() { var code = string.Concat(this.GenerateUsingStatements(), this.GenerateNamespace(), this.GenerateInterfaceDeclarations(), this.ExtendTypeWithPartial(), this.GenerateBuilder() ); return FormatCode(code); } private string GenerateUsingStatements() { var builder = new StringBuilder(); builder.AppendLine("using Vonage.Common.Client;"); builder.AppendLine("using Vonage.Common.Monads;"); builder.AppendLine("using Vonage.Common.Validation;"); var parameters = type.GetAttributes() .First(attr => attr.AttributeClass!.Name == "BuilderAttribute") .ConstructorArguments; if (parameters.Any()) { parameters.First().Values.Select(value => value.Value?.ToString()).ToList() .ForEach(value => builder.AppendLine($"using {value};")); } return builder.ToString(); } private string GenerateNamespace() => string.IsNullOrEmpty(type.ContainingNamespace?.ToDisplayString()) ? string.Empty : $"namespace {type.ContainingNamespace.ToDisplayString()};\n\n"; private string GenerateInterfaceDeclarations() => string.Concat(this.GetBuilderInterfaces().Select(builderInterface => builderInterface.BuildDeclaration())); // ... 
Enter fullscreen mode Exit fullscreen mode

Example: from Request to Builder

You'll find below an example: VerifyCodeRequest, which allows us to verify a 2FA code with our [Verify (https://developer.vonage.com/en/verify/overview) API.

This is the code from my initial request:

namespace Vonage.VerifyV2.VerifyCode; [Builder] // This request requires a builder public readonly partial struct VerifyCodeRequest : IVonageRequest { [Mandatory(1)] // Code is mandatory, and second property to set public string Code { get; internal init; } [JsonIgnore] [Mandatory(0)] // RequestId is mandatory, and first property to set public Guid RequestId { get; internal init; } ... [ValidationRule] // ValidationRule for our builder internal static Result<VerifyCodeRequest> VerifyCodeNotEmpty( VerifyCodeRequest request) => InputValidation .VerifyNotEmpty(request, request.Code, nameof(request.Code)); [ValidationRule] // ValidationRule for our builder internal static Result<VerifyCodeRequest> VerifyRequestIdNotEmpty( VerifyCodeRequest request) => InputValidation.VerifyNotEmpty(request, request.RequestId, nameof(request.RequestId)); } 
Enter fullscreen mode Exit fullscreen mode

And this is the builder (VerifyCodeRequestBuilder.g.cs), generated by our source generator:

using Vonage.Common.Client; using Vonage.Common.Monads; using Vonage.Common.Validation; namespace Vonage.VerifyV2.VerifyCode; public interface IBuilderForRequestId { IBuilderForCode WithRequestId(System.Guid value); } public interface IBuilderForCode { IBuilderForOptional WithCode(string value); } public interface IBuilderForOptional : IVonageRequestBuilder<VerifyCodeRequest> { } public partial struct VerifyCodeRequest { public static IBuilderForRequestId Build() => new VerifyCodeRequestBuilder(); } internal struct VerifyCodeRequestBuilder : IBuilderForRequestId, IBuilderForCode, IBuilderForOptional { private System.Guid requestid; private string code; public VerifyCodeRequestBuilder() { this.requestid = default; this.code = default; } public IBuilderForCode WithRequestId(System.Guid value) => this with { requestid = value }; public IBuilderForOptional WithCode(string value) => this with { code = value }; public Result<VerifyCodeRequest> Create() => Result<VerifyCodeRequest>.FromSuccess(new VerifyCodeRequest { RequestId = this.requestid, Code = this.code, }).Map(InputEvaluation<VerifyCodeRequest>.Evaluate).Bind(evaluation => evaluation.WithRules(VerifyCodeRequest.VerifyCodeNotEmpty, VerifyCodeRequest.VerifyRequestIdNotEmpty)); } 
Enter fullscreen mode Exit fullscreen mode

You probably noticed a couple of other things:

  • Our builder is in the same namespace as our request (Vonage.VerifyV2.VerifyCode), making it transparent for people who uses our code. They don't know, and they don't need to know, that the builder has been automatically generated.
  • We also extend our partial Request to add a static method Build() to initialize the builder with Request.Build()

Finally, here's a usage example:

var request = VerifyCodeRequest.Build() .WithRequestId(Guid.Parse("68c2b32e-55ba-4a8e-b3fa-43b3ae6cd1fb")) .WithCode("123456789") .Create() 
Enter fullscreen mode Exit fullscreen mode

In my case, my builders were already thoroughly tested before I introduced source generators. I used my test suite to guarantee that no builder's public api changed in the process, making sure that not a single breaking change was introduced.

Testing

Talking about testing, it's relatively straightforward.

You need to initialize your source generator (BuilderGenerator is the generator we created earlier), run the generation and compare the result with your expectation:

private static string GenerateCode(string inputCode) { var compilation = CSharpCompilation.Create(TestNamespace, [CSharpSyntaxTree.ParseText(inputCode)], [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); var driver = CSharpGeneratorDriver.Create(new AttributesGenerator(), new BuilderGenerator()); driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); return outputCompilation.SyntaxTrees.Last().ToString().Trim(); } [Theory] [InlineData("...")] [InlineData("...")] [InlineData("...")] [InlineData("...")] [InlineData("...")] public void VerifyCodeGeneration(string sample) { var inputCode = File.ReadAllText($"Files/{sample}_Input.txt").Trim(); var expectedGeneratedCode = File.ReadAllText($"Files/{sample}_Expected.txt").Trim(); Assert.Equal(NormalizeLineBreaks(expectedGeneratedCode), NormalizeLineBreaks(GenerateCode(inputCode))); } 
Enter fullscreen mode Exit fullscreen mode

Each test data focuses on a specific feature.
For example, here's my input file when testing a mandatory property with a validation rule:

namespace Vonage.TestProduct.TestRequest; using Vonage.Common.Validation; [Builder] public readonly partial struct MyRequest : IVonageRequest { [Mandatory(0)] public string Name { get; internal init; } [ValidationRule] internal static Result<MyRequest> VerifyName(MyRequest request) => InputValidation.VerifyNotEmpty(request, request.Name, nameof(request.Name)); } 
Enter fullscreen mode Exit fullscreen mode

And here's the expected output that I will compare to the generator's output:

using Vonage.Common.Client; using Vonage.Common.Monads; using Vonage.Common.Validation; namespace Vonage.TestProduct.TestRequest; public interface IBuilderForName { IBuilderForOptional WithName(string value); } public interface IBuilderForOptional : IVonageRequestBuilder<MyRequest> { } public partial struct MyRequest { public static IBuilderForName Build() => new MyRequestBuilder(); } internal struct MyRequestBuilder : IBuilderForName, IBuilderForOptional { private string name; public MyRequestBuilder() { this.name = default; } public IBuilderForOptional WithName(string value) => this with { name = value }; public Result<MyRequest> Create() => Result<MyRequest>.FromSuccess(new MyRequest { Name = this.name, }).Map(InputEvaluation<MyRequest>.Evaluate).Bind(evaluation => evaluation.WithRules(MyRequest.VerifyName)); } 
Enter fullscreen mode Exit fullscreen mode

Resources

Everything I shared above is publicly available.

Whether you'd like to verify I'm not lying, see the entire implementation or take some code, please yourself; it's all open-source code.

Wrapping up

With source generators, I was (so far) able to remove roughly 1'500 lines of repetitive code without making compromises on functionalities, nor any change in my builder's public api. It is also much easier to add new builders now: a couple attributes on a request, and we're ready to roll.

The next step is to iterate on my source generator to support more advanced use cases.I already handle more specific use cases that I didn't detailed above, like

  • Optional Booleans which defines a default value and exposes an explicit method to change the state, like default is true, and "Disable" sets the value to false. This improves the experience overall by making the intent explicit, instead of relying on true/false values.
  • Optional values with default which allows setting default values for Optional properties.

For sure, I won't be able to replace all my builders; but being able to substitute standard ones is already a big win.

I hope you learned something today.
Happy coding, and see you soon!

Top comments (0)