blog post image
Andrew Lock avatar

Andrew Lock

~10 min read

Using the new configuration binder source generator

Exploring the .NET 8 preview - Part 1

This is the first post in a new series, in which I look at some of the new features coming in the .NET 8 previews. In this post, I look at the new source generator introduced targeting the Microsoft.Extensions.Configuration configuration binder.

As these posts are all using the preview builds, some of the features may change (or be removed) before .NET 8 finally ships in November 2023!

Why do we need more source generators?

Source generators were introduced in .NET 6 as a feature that allows generating additional code at compile time, based on your code. I've written about source generators many times on my blog, as they provide a solution to many interesting problems. Fundamentally, they allow you to automatically generate code that would be difficult or laborious to write manually.

Take my EnumExtensions source generator for example. This generator provides fast alternatives to reflection-based enum methods. For example, if you have an enum defined like this:

[EnumExtensions]
public enum MyEnum
{
    First,
    Second,
}

the source generator generates the following extension method (among others):

public static partial class MyEnumExtensions
{
    public static string ToStringFast(this MyEnum value)
        => value switch
        {
            MyEnum.First => nameof(MyEnum.First),
            MyEnum.Second => nameof(MyEnum.Second),
            _ => value.ToString(),
        };
}

Calling this extension with either of the defined values can be orders-of-magnitude faster than using the "built in" ToString() method:

MethodFXMeanErrorStdDevRatioGen 0Allocated
ToStringnet48578.276 ns3.3109 ns3.0970 ns1.0000.045896 B
ToStringFastnet483.091 ns0.0567 ns0.0443 ns0.005--
ToStringnet6.017.9850 ns0.1230 ns0.1151 ns1.0000.011524 B
ToStringFastnet6.00.1212 ns0.0225 ns0.0199 ns0.007--

The extension isn't doing anything that you couldn't do by hand, but the important point is that it automates keeping the ToStringFast() method updated. When you add a new member to MyEnum, the ToStringFast() extension automatically updates; you don't have to remember to update it yourself!

Another big advantage of source generators is that they can remove the runtime dependence of your app on reflection. This may have performance benefits, though in many cases using reflection can be reduced to a one-time cost. A more important aspect for ahead-of-time (AOT) compilation is that using source generation makes your code statically analyzable.

A key part of AOT compilation is trimming (sometimes called tree shaking) in which all the pieces of your app which aren't actually used are removed from the final binaries. This is important for keeping AOT apps small. If your app uses runtime reflection, the compiler doesn't have an easy way to tell which parts of your app are or aren't used, which causes problems for AOT. Replacing the reflection with source generated code makes your app more AOT friendly.

This is the main reason behind the introduction of the configuration binder source generator. AOT is a priority for ASP.NET Core apps in .NET 8 (only minimal APIs and gRPC apps are going to be supported currently) and part of that work involves making it easier to have AOT-friendly apps. Currently, the configuration binding system in .NET relies on reflection; introducing a source generator replaces that with AOT-friendly generated code.

How is configuration binding used?

ASP.NET Core relies heavily on the "options" pattern. This is another topic I've talked about extensively on my blog as there are lots of subtleties and edge cases to be aware of. At a high level, the options pattern involves two concepts:

  • Multi-layered configuration. You can load configuration values from multiple sourcesโ€”JSON files, XML files, Azure Key Vault, environment variables etcโ€”and these are condensed into a dictionary of string key-value pairs.
  • Binding C# objects to the configuration.

The second point is crucial to make working with configuration pleasant. Instead of having to do something like the following, manually parsing values out of the configuration:

public App(IConfiguration configuration)
{
    var rawValue = configuration.GetSection("AppFeatures")["RateLimit"];
    if (!string.IsNullOrEmpty(rawValue))
    {
        _rateLimit = int.Parse(rawValue);
    }
    else
    {
        _rateLimit = 100; // default
    }
}

You can do something like this in your app setup code:

var builder = WebApplication.CreateBuilder(args);

var configSection = builder.Configuration.GetSection("AppFeatures");
builder.Services.Configure<AppFeaturesSettings>(configSection);

public class AppFeaturesSettings
{
    public int? RateLimit { get; set; }
}

The AppFeaturesSettings class is "bound" to the configuration section, automatically handling parsing values out of the config. You can then inject the AppFeaturesSettings object into your app:

public App(IOptions<AppFeaturesSettings> settings)
{
    _rateLimit = settings.Value.RateLimit ?? 100;
}

No, I don't like the IOptions<> dependency in my app either, but there are well-known ways around that, as I describe in my book!

The Configure<T> method is an extension method which ultimately makes calls to ConfigurationBinder, a set of extension methods in Microsoft.Extensions.Configuration that handle the messy work of finding all the bindable properties on the options type T, parsing the values from configuration, and setting them.

How does configuration binding currently work under the hood?

As you might have guessed, currently, ConfigurationBinder uses reflection to handle this process. Regardless of which Configure<T> extension method you use (or if you call Bind() directly), you ultimately end up calling into the BindInstance() method:

private static void BindInstance(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type,
    BindingPoint bindingPoint,
    IConfiguration config,
    BinderOptions options)
{
    // ...
}

This method takes a Type that will be bound, an IConfiguration to bind from, and some extra options (that we'll ignore for now). This method inspects the provided type and checks if it can bind to it directly because the type is a "primitive" type that can be bound directly from a string.

If not, and Type is a complex object like the AppFeaturesSettings from before, BindInstance calls BindProperties which uses reflection to find all bindable properties on the type, and then recursively calls BindInstance to bind the property.

This behaviour allows you to recursively bind all the properties on your type (and in nested types), parse the values from configuration appropriately, and set them on your options object.

Unfortunately, as I pointed out earlier, the use of reflection makes this method unfriendly to AOT compilation. That's where the source generator comes in.

Installing and enabling the configuration binder source generator

In this section I'll show how to install and enable the configuration binder source generator in your application. I actually tested this in a .NET 7 app, even though the source generator was introduced in .NET 8 preview 3.

You can install the configuration binder source generator in a .NET 6, .NET 7, or .NET 8 (preview) app by installing the Microsoft.Extensions.Configuration.Binder package:

dotnet add package Microsoft.Extensions.Configuration.Binder --version 8.0.0-preview.3.23174.8

Note that I'm installing the Preview 3 package here, as I found issues with the preview 4 and 5 packages, as I describe later.

This package includes the source generator, but it's disabled by default. To enable it you need to set an MSBuild property in your project:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <!-- ๐Ÿ‘‡ Required, as you may get namespace issues without it currently-->
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- ๐Ÿ‘‡ Enable generator in Preview 3-->
    <EnableMicrosoftExtensionsConfigurationBinderSourceGenerator>true</EnableMicrosoftExtensionsConfigurationBinderSourceGenerator>
    <!-- ๐Ÿ‘‡ Enable generator in Preview 4+-->
    <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0-preview.3.*" />
  </ItemGroup>
</Project>

Note that the MSBuild property to set changed between preview 3 and preview 4:

  • In preview 3, use EnableMicrosoftExtensionsConfigurationBinderSourceGenerator
  • In preview 4+, use EnableConfigurationBindingGenerator

Obviously this may well change again before the final release!

Once you've enabled this for your project, you're finished! There's no code changes required in your project, the calls to Configure<T> and Bind will just magically use source generated code! You can see this in your IDE by pressing F12 on the call to Configure<> - if the source generator is running, this should jump straight to the generated code! If it's not running, you'll likely end up at some decompiled/source link code in the OptionsConfigurationServiceCollectionExtensions class!

You can also have the compiler emit the generated code to disk, which can make it easier to work with. I describe how to use this approach in a previous blog post.

I was intrigued to understand how the source generator was pulling off this neat trick of forcing the callsite code to invoke the generator instead of the original function, so I took a peek at the generated code.

Looking at the generated code

Without further ado, let's look at an example of the generated code for my toy example. In the following example, I'm binding the AppFeaturesSettings by calling Configure<>().

var builder = WebApplication.CreateBuilder(args);

var configSection = builder.Configuration.GetSection("AppFeatures");
builder.Services.Configure<AppFeaturesSettings>(configSection); // ๐Ÿ‘ˆ Calls the source generator

public class AppFeaturesSettings
{
    public int? RateLimit { get; set; }
}

when the source generator is enabled, the following code is generated (approximately, I've tidied it up by extracting common using statements to make it easier to read):

// <auto-generated/>
#nullable enable

using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

internal static class GeneratedConfigurationBinder
{
    public static IServiceCollection Configure<T>(this IServiceCollection services, IConfiguration configuration)
    {
        if (typeof(T) == typeof(AppFeaturesSettings))
        {
            return services.Configure<AppFeaturesSettings>(obj =>
            {
                BindCore(configuration, ref obj);
            });
        }

        throw new NotSupportedException($"Unable to bind to type '{typeof(T)}': 'Generator parser did not detect the type as input'");
    }

    private static void BindCore(IConfiguration configuration, ref AppFeaturesSettings obj)
    {
        if (obj is null)
        {
            throw new ArgumentNullException(nameof(obj));
        }

        if (configuration["RateLimit"] is string stringValue1)
        {
            obj.RateLimit = int.Parse(stringValue1);
        }

    }

    public static bool HasChildren(IConfiguration configuration)
    {
        foreach (IConfigurationSection section in configuration.GetChildren())
        {
            return true;
        }
        return false;
    }
}

For my simple example, the generated code is relatively simple. The Configure<T> method checks that the provided type is one for which source has been generated (it always should be), and then calls BindCore() to do the binding. BindCore() just does the "manual" binding and parsing that I described previously, so there's no real magic to it.

The really neat trick is in making existing code call the source generator without any code changes! How is the generator managing to "intercept" the existing call:

// Why does this ๐Ÿ‘‡ suddenly call the source generator instead of the existing extension method
builder.Services.Configure<AppFeaturesSettings>(configSection); 

The answer can be found if we carefully examine the method signatures. The library method signature is:

namespace Microsoft.Extensions.DependencyInjection;

public static class OptionsConfigurationServiceCollectionExtensions
{
    public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection Configure<T>(
        this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, 
        global::Microsoft.Extensions.Configuration.IConfiguration configuration)
    where T : class
    {
        // ...
    }
}

While the source generated signature is:

internal static class GeneratedConfigurationBinder
{
    public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection Configure<T>(
        this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, 
        global::Microsoft.Extensions.Configuration.IConfiguration configuration)
    {
        // ...
    }
}

There are only 3 real differences here:

  • The classes containing the extension methods are different.
  • The library method has an additional class generic constraint.
  • The library class is defined in the Microsoft.Extensions.DependencyInjection namespace, while the generated class is defined in the global namespace.

As I understand it, the key to the source generated code's "overriding" behaviour is that the generated code is placed in the global namespace. The method lookup precedence favours the global namespace, which means that the source generated extension method is selected instead of the one in OptionsConfigurationServiceCollectionExtensions!

As a point of interest, the original github issue describing the source generator mentions:

As a next step we want to use the call site replace feature being developed by Roslyn to replace user calls with generated calls directly.

After a bit of digging, I think this is referring to the Interceptors proposal discussed here. It's been prototyped but not implemented yet, so I'm keeping an eye on it to see what happens!

So the final question is: is the source generator ready to go? Is there anything that doesn't work?

What doesn't work currently?

The source generator implemented in .NET 8 preview 3 is just a "first draft". There are known rough edges, as documented in this issue, which are expected to be completed in the next few previews. Most of these issues are about getting as close to parity with the reflection implementation as possible.

One point that seems like it may run into issues is how TypeConverter will be handled. If you rely on TypeConverter for your configuration binding, it might be worth chiming in on that issue.

I did a quick test with various options types and there was only one main case I ran into where the binding results were different:

public class BindableOptions
{
    public IEnumerable<SubClass> IEnumerable { get; set; }
}

The reflection-based binder will happily bind the IEnumerable property, but the source generator skips it in Preview 3. This has actually already been fixed in this PR, but we'll need to wait till preview 6 at least to test it!

A bigger problem I ran into was that both the Preview 4 and Preview 5 versions of the package generate code that won't compile.๐Ÿ˜ฑ The following shows the preview 4 output, preview 5 is broken in a slightly different way ๐Ÿ˜…

// <auto-generated/>
#nullable enable

internal static class GeneratedConfigurationBinder
{
    public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection Configure<T>(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::Microsoft.Extensions.Configuration.IConfiguration configuration)
    {
        if (configuration is null)
        {
            throw new global::System.ArgumentNullException(nameof(configuration));
        }

        if (typeof(T) == typeof(global::AppFeaturesSettings))
        {
            return services.Configure<global::AppFeaturesSettings>(obj =>
            {
                if (!global::Microsoft.Extensions.Configuration.Binder.SourceGeneration.Helpers.HasValueOrChildren(configuration))
                {
                    // ๐Ÿ‘‡  Error CS8030 : Anonymous function converted to a void returning delegate cannot return a value
                    return default;
                }

                global::Microsoft.Extensions.Configuration.Binder.SourceGeneration.Helpers.BindCore(configuration, ref obj);
            });
        }

        throw new global::System.NotSupportedException($"Unable to bind to type '{typeof(T)}': 'Generator parser did not detect the type as input'");
    }
}
// ... additional generated code not shown

The good news is that this is now fixed, so hopefully preview 6 will work perfectly, but in the mean time, preview 3 works great! ๐Ÿ˜†

Summary

In this post I looked at the new configuration binding source generator introduced in .NET preview 3. Replacing the reflection used by the default configuration binder is required for AOT compilation, which is targeted for ASP.NET Core in .NET 8. The configuration binding source generator uses method resolution rules to override the callsite of Configure<T> and Bind() invocations with the source generated version.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?