blog post image
Andrew Lock avatar

Andrew Lock

~8 min read

Looking inside ConfigurationManager in .NET 6

Exploring .NET 6 - Part 1

In this series I'm going to take a look at some of the new features coming in .NET 6. There's already been a lot of content written on .NET 6, including a lot of posts from the .NET and ASP.NET teams themselves. In this series I'm going to be looking at some of the code behind some of those features.

In this first post, I take a look at the ConfigurationManager class, why it was added, and some of the code used to implement it.

Wait, what's ConfigurationManager?

If your first response is "what's ConfigurationManager", then don't worry, you haven't missed a big announcement!

ConfigurationManager was added to support ASP.NET Core's new WebApplication model, used for simplifying the ASP.NET Core startup code. However ConfigurationManager is very much an implementation detail. It was introduced to optimise a specific scenario (which I'll describe shortly), but for the most part, you don't need to (and won't) know you're using it.

Before we get to the ConfigurationManager itself, we'll look at what it's replacing and why.

Configuration in .NET 5

.NET 5 exposes multiple types around configuration, but the two primary ones you use directly in your apps are:

  • IConfigurationBuilder - used to add configuration sources. Calling Build() on the builder reads each of the configuration sources, and builds the final configuration.
  • IConfigurationRoot - represents the final "built" configuration.

The IConfigurationBuilder interface is mostly a wrapper around a list of configuration sources. Configuration providers typically include extension methods (like AddJsonFile() and AddAzureKeyVault()) that add a configuration source to the Sources list.

public interface IConfigurationBuilder
{
    IDictionary<string, object> Properties { get; }
    IList<IConfigurationSource> Sources { get; }
    IConfigurationBuilder Add(IConfigurationSource source);
    IConfigurationRoot Build();
}

The IConfigurationRoot meanwhile represents the final "layered" configuration values, combining all the values from each of the configuration sources to give a final "flat" view of all the configuration values.

The later configuration providers (Environment Variables) overwrite the values added by earlier configuration providers (appsettings.json, sharedsettings.json). Taken from my book, ASP.NET Core in Action, Second Edition

In .NET 5 and earlier, the IConfigurationBuilder and IConfigurationRoot interfaces are implemented by ConfigurationBuilder and ConfigurationRoot respectively. If you were using the types directly, you might do something like this:

var builder = new ConfigurationBuilder();

// add static values
builder.AddInMemoryCollection(new Dictionary<string, string>
{
    { "MyKey", "MyValue" },
});

// add values from a json file
builder.AddJsonFile("appsettings.json");

// create the IConfigurationRoot instance
IConfigurationRoot config = builder.Build();

string value = config["MyKey"]; // get a value
IConfigurationSection section = config.GetSection("SubSection"); //get a section

In a typical ASP.NET Core app you wouldn't be creating the ConfigurationBuilder yourself, or calling Build(), but otherwise this is what's happening behind the scenes. There's a clear separation between the two types, and for the most part, the configuration system works well, so why do we need a new type in .NET 6?

The "partial configuration build" problem in .NET 5

The main problem with this design is when you need to "partially" build configuration. This is a common problem when you store your configuration in a service such as Azure Key Vault, or even in a database.

For example, the following is the suggested way to read secrets from Azure Key Vault inside ConfigureAppConfiguration() in ASP.NET Core:

.ConfigureAppConfiguration((context, config) =>
{
    // "normal" configuration etc
    config.AddJsonFile("appsettings.json");
    config.AddEnvironmentVariables();

    if (context.HostingEnvironment.IsProduction())
    {
        IConfigurationRoot partialConfig = config.Build(); // build partial config
        string keyVaultName = partialConfig["KeyVaultName"]; // read value from configuration
        var secretClient = new SecretClient(
            new Uri($"https://{keyVaultName}.vault.azure.net/"),
            new DefaultAzureCredential());
        config.AddAzureKeyVault(secretClient, new KeyVaultSecretManager()); // add an extra configuration source
        // The framework calls config.Build() AGAIN to build the final IConfigurationRoot
    }
})

Configuring the Azure Key Vault provider requires a configuration value, so you're stuck with a chicken and egg problem—you can't add the configuration source until you have built the configuration!

The solution is to:

  • Add the "initial" configuration values
  • Build the "partial" configuration result by calling IConfigurationBuilder.Build()
  • Retrieve the required configuration values from the resulting IConfigurationRoot
  • Use these values to add the remaining configuration sources
  • The framework calls IConfigurationBuilder.Build() implicitly, generating the final IConfigurationRoot and using that for the final app configuration.

This whole dance is a little messy, but there's nothing wrong with it per-se, so what's the downside?

The downside is that we have to call Build() twice: once to build the IConfigurationRoot using only the first sources, and then again to build the IConfiguartionRoot using all the sources, including the Azure Key Vault source.

In the default ConfigurationBuilder implementation, calling Build() iterates over all of the sources, loading the providers, and passing these to a new instance of the ConfigurationRoot:

public IConfigurationRoot Build()
{
    var providers = new List<IConfigurationProvider>();
    foreach (IConfigurationSource source in Sources)
    {
        IConfigurationProvider provider = source.Build(this);
        providers.Add(provider);
    }
    return new ConfigurationRoot(providers);
}

The ConfigurationRoot then loops through each of these providers in turn and loads the configuration values.

public class ConfigurationRoot : IConfigurationRoot, IDisposable
{
    private readonly IList<IConfigurationProvider> _providers;
    private readonly IList<IDisposable> _changeTokenRegistrations;

    public ConfigurationRoot(IList<IConfigurationProvider> providers)
    {
        _providers = providers;
        _changeTokenRegistrations = new List<IDisposable>(providers.Count);

        foreach (IConfigurationProvider p in providers)
        {
            p.Load();
            _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
        }
    }
    // ... remainder of implementation
}

If you call Build() twice during your app startup, then all of this happens twice.

Generally speaking, there's no harm in fetching the data from a configuration source more than once, but it's unnecessary work, and often involves (relatively slow) reading of files etc.

This is such a common pattern, that in .NET 6 a new type was introduced to avoid this "re-building", ConfigurationManager.

Configuration Manager in .NET 6

As part of the "simplified" application model in .NET 6, the .NET team added a new configuration type, ConfigurationManager. This type implements both IConfigurationBuilder and IConfigurationRoot. By combining both implementations in a single type, .NET 6 can optimise the common pattern show in the previous section.

With ConfigurationManager, when an IConfigurationSource is added (when you call AddJsonFile() for example), the provider is immediately loaded, and the configuration is updated. This can avoid having to load the configuration sources more than once in the partial-build scenario.

Implementing this is a little harder than it sounds due to the IConfigurationBuilder interface exposing the sources as an IList<IConfigurationSource>:

public interface IConfigurationBuilder
{
    IList<IConfigurationSource> Sources { get; }
    // .. other members
}

The problem with this from the ConfigurationManager point of view, is that IList<> exposes Add() and Remove() functions. If a simple List<> was used, consumers could add and remove configuration providers without the ConfigurationManager knowing about it.

To work around this, ConfigurationManager uses a custom IList<> implementation. This contains a reference to the ConfigurationManager instance, so that any changes can be reflected in the configuration:

private class ConfigurationSources : IList<IConfigurationSource>
{
    private readonly List<IConfigurationSource> _sources = new();
    private readonly ConfigurationManager _config;

    public ConfigurationSources(ConfigurationManager config)
    {
        _config = config;
    }

    public void Add(IConfigurationSource source)
    {
        _sources.Add(source);
        _config.AddSource(source); // add the source to the ConfigurationManager
    }

    public bool Remove(IConfigurationSource source)
    {
        var removed = _sources.Remove(source);
        _config.ReloadSources(); // reset sources in the ConfigurationManager
        return removed;
    }

    // ... additional implementation
}

By using a custom IList<> implementation, ConfigurationManager ensures AddSource() is called whenever a new source is added. This is what gives ConfigurationManager its advantage: calling AddSource() immediately loads the source:

public class ConfigurationManager
{

    private void AddSource(IConfigurationSource source)
    {
        lock (_providerLock)
        {
            IConfigurationProvider provider = source.Build(this);
            _providers.Add(provider);

            provider.Load();
            _changeTokenRegistrations.Add(ChangeToken.OnChange(() => provider.GetReloadToken(), () => RaiseChanged()));
        }

        RaiseChanged();
    }
}

This method immediately calls Build on the IConfigurationSource to create the IConfigurationProvider, and adds it to the provider list.

Next, the method calls IConfigurationProvider.Load(). This loads the data into the provider, (e.g. from environment variables, a JSON file, or Azure Key Vault), and is the "expensive" step that this was all for! In the "normal" case, where you just add sources to the IConfigurationBuilder, and may need to build it multiple times, this gives the "optimal" approach; sources are loaded once, and only once.

The implementation of Build() in ConfigurationManager is now a noop, simply returning itself.

IConfigurationRoot IConfigurationBuilder.Build() => this;

Of course software development is all about trade-offs. Incrementally building sources when they're added works well if you only ever add sources. However, if you call any of the other IList<> functions like Clear(), Remove() or the indexer, the ConfigurationManager has to call ReloadSources()

private void ReloadSources()
{
    lock (_providerLock)
    {
        DisposeRegistrationsAndProvidersUnsynchronized();

        _changeTokenRegistrations.Clear();
        _providers.Clear();

        foreach (var source in _sources)
        {
            _providers.Add(source.Build(this));
        }

        foreach (var p in _providers)
        {
            p.Load();
            _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
        }
    }

    RaiseChanged();
}

As you can see, if any of the sources change, the ConfigurationManager has to remove everything and start again, iterating through each of the sources, reloading them. This could quickly get expensive if you're doing a lot of manipulation of configuration sources, and would completely negate the original advantage of ConfigurationManager.

Of course, removing sources would be very unusual—there's generally no reason to do anything other than add providers—so ConfigurationManager is very much optimised for the most common case. Who would have guessed it? 😉

The following table gives a final summary of the relative cost of various operations using both ConfigurationBuilder and ConfigurationManager.

OperationConfigurationBuilderConfigurationManager
Add sourceCheapModerately Expensive
Partially Build IConfigurationRootExpensiveVery cheap (noop)
Fully Build IConfigurationRootExpensiveVery cheap (noop)
Remove sourceCheapExpensive
Change sourceCheapExpensive

So, should I care about ConfigurationManager?

So having read all this way, should you care about whether you're using ConfigurationManager or ConfigurationBuilder?

Probably not.

The new WebApplicationBuilder introduced in .NET 6 uses ConfigurationManager, which optimises for the use case I described above where you need to partially build your configuration.

However, the WebHostBuilder or HostBuilder introduced in earlier versions of ASP.NET Core are still very much supported in .NET 6, and they continue to use the ConfigurationBuilder and ConfigurationRoot types behind the scenes.

The only situation I can think where you need to be careful is if you are somewhere relying on the IConfigurationBuilder or IConfigurationRoot being the concrete types ConfigurationBuilder or ConfigurationRoot. That seems very unlikely to me, and if you are relying on that, I'd be interested to know why!

But other than that niche exception, no the "old" types aren't going away, so there's no need to worry. Just be happy in the knowledge that if you need to do a "partial build", and you're using the new WebApplicationBuilder, your app will be a tiny bit more performant!

Summary

In this post I described the new ConfigurationManager type introduced in .NET 6 and used by the new WebApplicationBuilder used in minimal API examples. ConfigurationManager was introduced to optimise a common situation where you need to "partially build" configuration. This is typically because a configuration provider requires some configuration itself, for example loading secrets from Azure Key Vault requires configuration indicating which vault to use.

ConfigurationManager optimises this scenario by immediately loading sources as they're added, instead of waiting till you call Build(). This avoids the need for "rebuilding" the configuration in the "partial build" scenario. The trade-off is that other operations (such as removing a source) are expensive.

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