Implementing the Builder pattern with Metalama
The popularity of immutable objects has made the Builder pattern one of the most important in C#. However, implementing the Builder pattern by hand is a tedious and repetitive task. Fortunately, because it is repetitive, it can be automated using a Metalama aspect. This is what we will explore in this article. We will start discussing the implementation strategy, then we will comment the source code of the Metalama aspect.
Components of the Builder pattern
As with any pattern automation, the very first step is to describe how we would implement the process by hand. It’s a good practice to start with a few code snippets before transformation and to handwrite the code we want to generate. Once we think we have covered all the cases we can identify, we can reason about how to turn this into an algorithm.
Features
A proper implementation of the Builder pattern should include the following features:
- A
Builder
constructor accepting all required properties. - A writable property in the
Builder
type corresponding to each property of the build type. For properties returning an immutable collection, the property of theBuilder
type should be read-only but return the corresponding mutable collection type. - A
Builder.Build
method returning the built immutable object. - The ability to call an optional
Validate
method when an object is built. - In the source type, a
ToBuilder
method returning aBuilder
initialized with the current values.
Examples
Let’s start with a simple example:
[GenerateBuilder]
public partial class Song
{
[Required] public string Artist { get; }
[Required] public string Title { get; }
public TimeSpan? Duration { get; }
public string Genre { get; } = "General";
}
We want the [GenerateBuilder]
aspect to generate the following code:
public partial class Song
{
private Song(string artist, string title, TimeSpan? duration, string genre)
{
Artist = artist;
Title = title;
Duration = duration;
Genre = genre;
}
public Builder ToBuilder() => new Builder(this);
public class Builder
{
// Public constructor.
public Builder(string artist, string title)
{
Artist = artist;
Title = title;
}
// Copy constructor.
internal Builder(Song source)
{
Artist = source.Artist;
Title = source.Title;
Duration = source.Duration;
Genre = source.Genre;
}
public string Artist { get; set; }
public TimeSpan? Duration { get; set; }
public string Genre { get; set; } = "General";
public string Title { get; set; }
public Song Build()
{
var instance = new Song(Artist, Title, Duration, Genre)!;
return instance;
}
}
}
By the end of this article, we will be able to generate this code automatically on-the-fly during the build.
Here are some use cases for this code:
// Use case 1. Create from scratch.
var songBuilder = new Song.Builder( "Joseph Kabasele", "Indépendance Cha Cha" );
songBuilder.Genre = "Congolese rumba";
var song = songBuilder.Build();
// Use case 2. Create builder from existing object.
var songBuilder2 = song.ToBuilder();
songBuilder2.Duration = new TimeSpan(0, 3, 5);
var song2 = songBuilder.Build();
Implementation steps
Our [GenerateBuilder]
aspect will need to perform the following steps:
- Introduce a nested class named
Builder
with the following members:- A copy constructor initializing the
Builder
class from an instance of the source class. - A public constructor for users of our class, accepting values for all required properties.
- A writable property for each property of the source type.
- A
Build
method that instantiates the source type with the values set in theBuilder
, calling theValidate
method if present.
- A copy constructor initializing the
- Add the following members to the source type:
- A private constructor called by the
Builder.Build
method. - A
ToBuilder
method returning a newBuilder
initialized with the current instance.
- A private constructor called by the
Implementing the Builder pattern
In this article, I will only outline the major steps of the implementation. For a detailed implementation, see the Builder pattern example in the reference documentation.
Step 1. Create a Metalama aspect
The first step is to add the Metalama.Framework
package to your project:
<ItemGroup>
<PackageReference Include="Metalama.Framework"/>
</ItemGroup>
Then, create an aspect class:
public partial class GenerateBuilderAttribute : TypeAspect
The TypeAspect
class is an abstract base class for aspects that can be applied to types.
Step 2. Define some infrastructure
Before adding anything to the aspect, we need a data structure to store references to the declarations we generate. The PropertyMapping
type maps a property of the source code to its corresponding property in the Builder
type and in constructor parameters.
[CompileTime]
private record Tags(
INamedType SourceType,
IReadOnlyList<PropertyMapping> Properties,
IConstructor SourceConstructor,
IConstructor BuilderCopyConstructor);
[CompileTime]
private class PropertyMapping
{
public PropertyMapping(IProperty sourceProperty, bool isRequired)
{
this.SourceProperty = sourceProperty;
this.IsRequired = isRequired;
}
public IProperty SourceProperty { get; }
public bool IsRequired { get; }
public IProperty? BuilderProperty { get; set; }
public int? SourceConstructorParameterIndex { get; set; }
public int? BuilderConstructorParameterIndex { get; set; }
}
Note that we added the [CompileTime]
attribute to these classes because they need to be accessible at compile time by the aspect.
Step 3. Identify the properties to be mapped
We can now start implementing the aspect. Its entry point is the BuildAspect
method. The first thing we do is create a list of properties.
public override void BuildAspect(IAspectBuilder<INamedType> builder)
{
base.BuildAspect(builder);
var sourceType = builder.Target;
// Create a list of PropertyMapping items for all properties that we want to build using the Builder.
var properties = sourceType.Properties.Where(
p => p.Writeability != Writeability.None &&
!p.IsStatic)
.Select(
p => new PropertyMapping(p,
p.Attributes.OfAttributeType(typeof(RequiredAttribute)).Any()))
.ToList();
Step 4. Introduce the Builder type and its properties
Let’s create a nested type using the IntroduceClass
method:
// Introduce the Builder nested type.
var builderType = builder.IntroduceClass(
"Builder",
buildType: t => t.Accessibility = Accessibility.Public);
Now we can add properties to our new Builder
type:
// Add builder properties and update the mapping.
foreach (var property in properties)
{
property.BuilderProperty =
builderType.IntroduceAutomaticProperty(
property.SourceProperty.Name,
property.SourceProperty.Type,
buildProperty: p =>
{
p.Accessibility = Accessibility.Public;
p.InitializerExpression = property.SourceProperty.InitializerExpression;
})
.Declaration;
}
Step 5. Creating the Builder public constructor
Our next task is to create the public constructor of the Builder
nested type, which should have parameters for all required properties. Let’s add this code to the BuildAspect
method:
// Add a builder constructor accepting the required properties and update the mapping.
builderType.IntroduceConstructor(
nameof(this.BuilderConstructorTemplate),
buildConstructor: c =>
{
c.Accessibility = Accessibility.Public;
foreach (var property in properties.Where(m => m.IsRequired))
{
var parameter = c.AddParameter(
NameHelper.ToParameterName(property.SourceProperty.Name),
property.SourceProperty.Type);
property.BuilderConstructorParameterIndex = parameter.Index;
}
});
Here is BuilderConstructorTemplate
, the template for this constructor. You can see how we use the Tags
and PropertyMapping
objects. This code iterates through required properties and assigns a property of the Builder
type to the value of the corresponding constructor parameter.
[Template]
private void BuilderConstructorTemplate()
{
var tags = (Tags)meta.Tags.Source!;
foreach (var property in tags.Properties.Where(p => p.IsRequired))
{
property.BuilderProperty!.Value =
meta.Target.Parameters[property.BuilderConstructorParameterIndex!.Value].Value;
}
}
Step 6. Adding a constructor to the source type
Before we implement the Build
method, we must implement the constructor in the source type. This code snippet from the BuildAspect
method creates the constructor and its parameters:
// Add a constructor to the source type with all properties.
var sourceConstructor = builder.IntroduceConstructor(
nameof(this.SourceConstructorTemplate),
buildConstructor: c =>
{
c.Accessibility = Accessibility.Private;
foreach (var property in properties)
{
var parameter = c.AddParameter(
NameHelper.ToParameterName(property.SourceProperty.Name),
property.SourceProperty.Type);
property.SourceConstructorParameterIndex = parameter.Index;
}
})
.Declaration;
The template for this constructor is SourceConstructorTemplate
. It simply assigns properties based on constructor parameters.
[Template]
private void SourceConstructorTemplate()
{
var tags = (Tags)meta.Tags.Source!;
foreach (var property in tags.Properties)
{
property.SourceProperty.Value =
meta.Target.Parameters[property.SourceConstructorParameterIndex!.Value].Value;
}
}
Step 7. Implementing the Build method
The Build
method of the Builder
type is responsible for creating an instance of the source (immutable) type from the values of the Builder
.
// Add a Build method to the builder.
builderType.IntroduceMethod(
nameof(this.BuildMethodTemplate),
IntroductionScope.Instance,
buildMethod: m =>
{
m.Name = "Build";
m.Accessibility = Accessibility.Public;
m.ReturnType = sourceType;
});
The T# template for the Build
method first invokes the newly introduced constructor, then tries to find and call the optional Validate
method before returning the new instance of the source type.
[Template]
private dynamic BuildMethodTemplate()
{
var tags = (Tags)meta.Tags.Source!;
// Build the object.
var instance = tags.SourceConstructor.Invoke(
tags.Properties.Select(x => x.BuilderProperty!))!;
// Find and invoke the Validate method, if any.
var validateMethod = tags.SourceType.AllMethods.OfName("Validate")
.SingleOrDefault(m => m.Parameters.Count == 0);
if (validateMethod != null)
{
validateMethod.With((IExpression)instance).Invoke();
}
// Return the object.
return instance;
}
Next implementation steps
I hope the previous steps gave you an idea of how Metalama works. Automating the implementation of the Builder pattern requires a few more steps, all covered in the Builder pattern example:
- Generating the
ToBuilder
method - Coping with base and derived types
- Handling collection types
Is it worth it?
As you can see, even with Metalama, automating the Builder pattern is not completely trivial. So, is it worth it?
It depends on how often the aspect will be used in your application. Typically, if an aspect is used fewer than a dozen times, automation may not be worthwhile. However, if you’re planning a large project with dozens or even hundreds of classes that would benefit from the builder pattern, then automating it is definitely worth the effort.
Remember, every project will have slightly different requirements for implementing the Builder pattern. To save time, start with the Builder pattern example, understand its principles, and customize it to your needs.
The benefit of automating the pattern implementation as an aspect is that when you want to change the pattern, you only have to edit a single class: the aspect.
Wrapping up
The Builder pattern has become one of the most important patterns in modern .NET, thanks to its focus on immutability. With the Builder pattern, you get the convenience of mutability during the configuration stage of a component, coupled with the safety and simplicity of immutability after the component has been built.
Implementing the Builder pattern traditionally involves a lot of boilerplate code. Fortunately, tools like Metalama make it easier to automate its implementation.