DEV Community

Gael Fraiteur for PostSharp Technologies

Posted on • Originally published at blog.postsharp.net on

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 the Builder 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 a Builder 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";
}

Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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 the Builder, calling the Validate method if present.
  • Add the following members to the source type:
    • A private constructor called by the Builder.Build method.
    • A ToBuilder method returning a new Builder initialized with the current instance.

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>
Enter fullscreen mode Exit fullscreen mode

Then, create an aspect class:

public partial class GenerateBuilderAttribute : TypeAspect
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

Note that we added the [CompileTime] attribute to these classes because they need to be accessible at compile time by the aspect.

The full source code of examples in this article is available on GitHub.

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,
                 required:  p.Attributes.OfAttributeType(
                              typeof(RequiredAttribute)).Any()))
        .ToList();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    });
Enter fullscreen mode Exit fullscreen mode

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 =            
            eta.Target.Parameters[
                property.BuilderConstructorParameterIndex!.Value].Value;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

This article was first published on a https://blog.postsharp.net under the title Implementing the Builder pattern with Metalama.

Top comments (0)