blog post image
Andrew Lock avatar

Andrew Lock

~12 min read

Exploring the code behind WebApplicationBuilder

Exploring .NET 6 - Part 3

In my previous post I compared the new WebApplication to the Generic Host. In this post I look at the code behind WebApplicationBuilder, to see how it achieves the cleaner, minimal, hosting API, while still providing the same functionality as the generic host.

WebApplication and WebApplicationBuilder: the new way to bootstrap ASP.NET Core applications

.NET 6 introduced a completely new way to "bootstrap" an ASP.NET Core application. Instead of the traditional split between Program.cs and Startup, the entire bootstrapping code goes in Program.cs, and is far more procedural than the raft of lambda methods required by the Generic Host in previous versions:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();

WebApplication app = builder.Build();

app.UseStaticFiles();

app.MapGet("/", () => "Hello World!");
app.MapRazorPages();

app.Run();

There are various C# updates which make this all appear cleaner (top level statements, implicit usings, inferred lambda types etc), but there are also two new types at play: WebApplication and WebApplicationBuilder. In the previous post I described briefly how you use WebApplication and WebApplicationBuilder to configure your ASP.NET Core application. In this post we're going to look at the code behind them, to see how they achieve a simpler API, while still having the same flexibility and configurability as the Generic Host.

Creating a WebApplicationBuilder

The first step in our example program is to create an instance of WebApplicationBuilder using the static method on WebApplication:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

This instantiates a new instance of WebApplicationOptions assigns the Args arguments from the command line arguments, and passes the options object to the WebApplicationBuilder constructor (shown shortly):

public static WebApplicationBuilder CreateBuilder(string[] args) =>
    new(new() { Args = args });

As an aside, target-typed new when the target type is not obvious sucks for discoverability. There's no way to even guess what the second new() is creating in the code above. A lot of this is the same arguments for not always using var, but I found it particularly irritating in this case.

WebApplicationOptions provides an easy way to programmatically override some important properties; if these aren't set, they'll be inferred by default as in previous versions.

public class WebApplicationOptions
{
    public string[]? Args { get; init; }
    public string? EnvironmentName { get; init; }
    public string? ApplicationName { get; init; }
    public string? ContentRootPath { get; init; }
}

The WebApplicationBuilder constructor is where the bulk of the sleight of hand takes place to make the minimal hosting concept work:

public sealed class WebApplicationBuilder
{
    internal WebApplicationBuilder(WebApplicationOptions options)
    {
        // .. shown below
    }

I haven't shown the body of the method yet, as there's a lot going on in this constructor, and lots of helper types, to achieve it. We'll come back to those in a second, for now we'll focus on the public API of WebApplicationBuilder.

The public API of WebApplicationBuilder

The public API of WebApplicationBuilder consists of a bunch of read only properties, and a single method, Build(), which creates a WebApplication.

public sealed class WebApplicationBuilder
{
    public IWebHostEnvironment Environment { get; }
    public IServiceCollection Services { get; }
    public ConfigurationManager Configuration { get; }
    public ILoggingBuilder Logging { get; }

    public ConfigureWebHostBuilder WebHost { get; }
    public ConfigureHostBuilder Host { get; }
    
    public WebApplication Build()
}

If you're familiar with ASP.NET Core, many of these properties use common types from previous versions:

  • IWebHostEnvironment: used to retrieve the environment name, ContentRootPath, and similar values.
  • IServiceCollection: used to register services with the DI container. Note that this is an alternative to the ConfigureServices() method used by the generic host to achieve the same thing.
  • ConfigurationManager: used to both add new configuration and retrieve configuration values. See my first post in the series for a discussion of this type.
  • ILoggingBuilder: used to register additional logging providers, just as you would with the ConfigureLogging() method in the generic host

The WebHost and Host properties are interesting, in that they expose new types, ConfigureWebHostBuilder and ConfigureHostBuilder. These types implement IWebHostBuilder and IHostBuilder respectively, and are primarily exposed as a way for you to use pre-.NET 6 extension methods with the new type. For example, in the previous post, I showed how you could register, Serilog's ASP.NET Core integration by calling UseSerilog() on the Host property:

builder.Host.UseSerilog();

Exposing the IWebHostBuilder and IHostBuilder interfaces was absolutely necessary to allow migration paths from pre .NET 6 apps to the new minimal hosting WebApplication, but it also proves to be a bit of a challenge. How can we reconcile the lambda/callback style configuration of IHostBuilder with the imperative style of WebApplicationBuilder? That's where ConfigureHostBuilder and ConfigureWebHostBuilder come in along with some internal IHostBuilder implementations:

Multiple IHostBuilder types used by WebApplicationBuilder

We'll start by looking at the public ConfigureHostBuilder and ConfigureWebHostBuilder.

ConfigureHostBuilder: an IHostBuilder escape hatch

ConfigureHostBuilder and ConfigureWebHostBuilder were added as part of the minimal hosting update. They implement IHostBuilder and IWebHostBuilder respectively, but I'm going to focus on ConfigureHostBuilder in this post:

public sealed class ConfigureHostBuilder : IHostBuilder, ISupportsConfigureWebHost
{
    // ...
}

ConfigureHostBuilder implements IHostBuilder, and it looks like it implements ISupportsConfigureWebHost, but a look at the implementation shows that's a lie:

IHostBuilder ISupportsConfigureWebHost.ConfigureWebHost(Action<IWebHostBuilder> configure, Action<WebHostBuilderOptions> configureOptions)
{
    throw new NotSupportedException($"ConfigureWebHost() is not supported by WebApplicationBuilder.Host. Use the WebApplication returned by WebApplicationBuilder.Build() instead.");
}

That means that although the following code compiles:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.ConfigureWebHost(webBuilder =>
{
    webBuilder.UseStartup<Startup>();
});

This throws a NotSupportedException at runtime. This is obviously not ideal, but it's the price we pay for having a nice imperative API for configuring services etc. The same is true for the IHostBuilder.Build() method - this throws a NotSupportedException.

It's never great to see these runtime exceptions, but it helps to think of the ConfigureHostBuilder as an "adapter" for existing extension methods (like the UseSerilog() method) rather than a "real" host builder. This becomes apparent when you see how methods such as ConfigureServices() or ConfigureAppConfiguration() are implemented on these types:

public sealed class ConfigureHostBuilder : IHostBuilder, ISupportsConfigureWebHost
{
    private readonly ConfigurationManager _configuration;
    private readonly IServiceCollection _services;
    private readonly HostBuilderContext _context;

    internal ConfigureHostBuilder(HostBuilderContext context, ConfigurationManager configuration, IServiceCollection services)
    {
        _configuration = configuration;
        _services = services;
        _context = context;
    }

    public IHostBuilder ConfigureAppConfiguration(Action<HostBuilderContext, IConfigurationBuilder> configureDelegate)
    {
        // Run these immediately so that they are observable by the imperative code
        configureDelegate(_context, _configuration);
        return this;
    }

    public IHostBuilder ConfigureServices(Action<HostBuilderContext, IServiceCollection> configureDelegate)
    {
        // Run these immediately so that they are observable by the imperative code
        configureDelegate(_context, _services);
        return this;
    }
}

The ConfigureServices() method, for example, immediately executes the supplied Action<> using the injected IServiceCollection from the WebApplicationBuilder. So the following two calls are functionally identical:

// directly registers the MyImplementation type with the IServiceContainer
builder.Services.AddSingleton<MyImplementation>();

// uses the "legacy" ConfigureServices method
builder.Host.ConfigureServices((ctx, services) => services.AddSingleton<MyImplementation>());

The latter approach is clearly not worth using in normal practice, but existing code that relies on this method (extension methods, for example) can still be used.

Not all of the delegates passed to methods in ConfigureHostBuilder run immediately. Some, such as UseServiceProviderFactory() are saved in a list, and are executed later in the call to WebApplicationBuilder.Build()

I think that covers the ConfigureHostBuilder type, and ConfigureWebHostBuilder is very similar, acting as an adapter from the previous API to the new imperative style. Now we can get back to the WebApplicationBuilder constructor.

The BootstrapHostBuilder helper

Before we took a diversion to look at ConfigureHostBuilder, we were about to look at the WebApplicationBuilder constructor. But we're still not ready for it yet… first we need to look at one more helper class, BootstrapHostBuilder.

BootstrapHostBuilder is an internal IHostBuilder implementation used by the WebApplicationBuilder. It's mostly a relatively simple implementation, which "remembers" all of the IHostBuilder calls it receives. For example, the ConfigureHostConfiguration() and ConfigureServices() functions look like the following:

internal class BootstrapHostBuilder : IHostBuilder
{
    private readonly List<Action<IConfigurationBuilder>> _configureHostActions = new();
    private readonly List<Action<HostBuilderContext, IServiceCollection>> _configureServicesActions = new();

    public IHostBuilder ConfigureHostConfiguration(Action<IConfigurationBuilder> configureDelegate)
    {
        _configureHostActions.Add(configureDelegate);
        return this;
    }

    public IHostBuilder ConfigureServices(Action<HostBuilderContext, IServiceCollection> configureDelegate)
    {
        _configureServicesActions.Add(configureDelegate);
        return this;
    }
    // ...
}

In contrast to the ConfigureHostBuilder which immediately executed a provided delegate, the BootstrapHostBuilder "saves" provided delegates to a list to be executed later. This is similar to the way the generic HostBuilder works. But note that BootstrapHostBuilder is another "non buildable" IHostBuilder, in that calling Build() throws an exception:

public IHost Build()
{
    throw new InvalidOperationException();
}

Most of the complexity of BootstrapHostBuilder is in its RunDefaultCallbacks(ConfigurationManager, HostBuilder) method. This is used to apply the stored delegates in the correct order as we'll see later.

The WebApplicationBuilder constructor

Finally, we come to the WebApplicationBuilder constructor. This contains a lot of code, so I'm going to walk through it piece by piece.

Note that I've taken a couple of liberties to remove tangential bits of code (e.g. guard clauses and testing code)

public sealed class WebApplicationBuilder
{
    private readonly HostBuilder _hostBuilder = new();
    private readonly BootstrapHostBuilder _bootstrapHostBuilder;
    private readonly WebApplicationServiceCollection _services = new();

    internal WebApplicationBuilder(WebApplicationOptions options)
    {
        Services = _services;
        var args = options.Args;

        // ...
    }

    public IWebHostEnvironment Environment { get; }
    public IServiceCollection Services { get; }
    public ConfigurationManager Configuration { get; }
    public ILoggingBuilder Logging { get; }
    public ConfigureWebHostBuilder WebHost { get; }
    public ConfigureHostBuilder Host { get; }
}

We start with the private fields and properties. _hostBuilder is an instance of the generic host, HostBuilder which is the "internal" host that backs the WebApplicationBuilder. We also have a BootstrapHostBuilder field (from the previous section) and an instance of WebApplicationServiceCollection which is an IServiceCollection implementation that I'll gloss over for now.

WebApplicationBuilder acts like an "adapter" for the generic host, _hostBuilder, providing the imperative API that I explored in my previous post while maintaining the same functionality as the generic host.

The next steps in the constructor are well documented, thankfully:

// Run methods to configure both generic and web host defaults early to populate config from appsettings.json
// environment variables (both DOTNET_ and ASPNETCORE_ prefixed) and other possible default sources to prepopulate
// the correct defaults.
_bootstrapHostBuilder = new BootstrapHostBuilder(Services, _hostBuilder.Properties);

// Don't specify the args here since we want to apply them later so that args
// can override the defaults specified by ConfigureWebHostDefaults
_bootstrapHostBuilder.ConfigureDefaults(args: null);

// We specify the command line here last since we skipped the one in the call to ConfigureDefaults.
// The args can contain both host and application settings so we want to make sure
// we order those configuration providers appropriately without duplicating them
if (args is { Length: > 0 })
{
    _bootstrapHostBuilder.ConfigureAppConfiguration(config =>
    {
        config.AddCommandLine(args);
    });
}

After creating an instance of the BootstrapHostBuilder, the first method call is to HostingBuilderExtension.ConfigureDefaults(). This is exactly the same method called by the generic host when you call Host.CreateDefaultBuilder().

Note that the args aren't passed into the ConfigureDefaults() call, and instead are applied later. The result is that the args aren't used to configure the host configuration at this stage (The host configuration determines values such as the application name and hosting environment).

The next method call is to GenericHostBuilderExtensions.ConfigureWebHostDefaults(), which is the same extension method we would normally call when using the generic host in ASP.NET Core 3.x/5.

_bootstrapHostBuilder.ConfigureWebHostDefaults(webHostBuilder =>
{
    // Runs inline.
    webHostBuilder.Configure(ConfigureApplication);

    // We need to override the application name since the call to Configure will set it to
    // be the calling assembly's name.
    var applicationName = (Assembly.GetEntryAssembly())?.GetName()?.Name ?? string.Empty;
    webHostBuilder.UseSetting(WebHostDefaults.ApplicationKey, applicationName);
});

This method effectively adds an IWebHostBuilder "adapter" over the top of the BootstrapHostBuilder, calls WebHost.ConfigureWebDefaults() on it, and then immediately runs the lambda method passed in. This registers the WebApplicationBuillder.ConfigureApplication() method to be called later, which sets up a whole bunch of middleware. We'll come back to that method in the next post.

With the web host configured, the next method applies the args to the host configuration, ensuring they correctly override the defaults set by the previous extension methods:

// Apply the args to host configuration last since ConfigureWebHostDefaults overrides a host specific setting (the application name).
_bootstrapHostBuilder.ConfigureHostConfiguration(config =>
{
    if (args is { Length: > 0 })
    {
        config.AddCommandLine(args);
    }

    // Apply the options after the args
    options.ApplyHostConfiguration(config);
});

Finally, the BootstrapHostBuilder.RunDefaultCallbacks() method is called, which runs all the stored callbacks we've accumulated so far in the correct order, to build the HostBuilderContext. The HostBuilderContext is then used to finally set the remaining properties on the WebApplicationBuilder.

Configuration = new();

// This is the application configuration
var hostContext = _bootstrapHostBuilder.RunDefaultCallbacks(Configuration, _hostBuilder);

// Grab the WebHostBuilderContext from the property bag to use in the ConfigureWebHostBuilder
var webHostContext = (WebHostBuilderContext)hostContext.Properties[typeof(WebHostBuilderContext)];

// Grab the IWebHostEnvironment from the webHostContext. This also matches the instance in the IServiceCollection.
Environment = webHostContext.HostingEnvironment;
Logging = new LoggingBuilder(Services);
Host = new ConfigureHostBuilder(hostContext, Configuration, Services);
WebHost = new ConfigureWebHostBuilder(webHostContext, Configuration, Services);

That's the end of the constructor. At this point the application is configured with all of the "hosting" defaults—configuration, logging, DI services, and environment etc.

Now you can add all your own services, extra configuration, or logging to the WebApplicationBuilder:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// add configuration
builder.Configuration.AddJsonFile("sharedsettings.json");

// add services
builder.Services.AddSingleton<MyTestService>();
builder.Services.AddRazorPages();

// add configuration
builder.Logging.AddFile();

// build!
WebApplication app = builder.Build();

Once you've finished with the app specific configuration, you call Build() to create a WebApplication instance. In the final section of this post, we look inside the Build() method.

Building a WebApplication with WebApplicationBuilder.Build()

The Build() method isn't very long, but it's a little hard to follow, so I'll walk through it line by line:

public WebApplication Build()
{
    // Copy the configuration sources into the final IConfigurationBuilder
    _hostBuilder.ConfigureHostConfiguration(builder =>
    {
        foreach (var source in ((IConfigurationBuilder)Configuration).Sources)
        {
            builder.Sources.Add(source);
        }

        foreach (var (key, value) in ((IConfigurationBuilder)Configuration).Properties)
        {
            builder.Properties[key] = value;
        }
    });

    // ...
}

The first thing we do is copy the configuration sources configured in the ConfigurationManager into the _hostBuilder's ConfigurationBuilder implementation. When this method is called, builder is initially empty, so this populates all the sources that were added both by the default builder extension methods, and extra sources you configured subsequently.

Note that technically the ConfigureHostConfiguration method doesn't run straight away. Rather, we are registering a callback that will be invoked when we call _hostBuilder.Build() shortly.

Next, we do a similar thing for the IServiceCollection, copying them from the _services instance, into the _hostBuilder's collection. The comments here are quite explanatory; in this case the _hostBuilder's services collection isn't completely empty (just mostly empty), but we add everything from Services and then "reset" Services to the _hostBuilder's instance.

// This needs to go here to avoid adding the IHostedService that boots the server twice (the GenericWebHostService).
// Copy the services that were added via WebApplicationBuilder.Services into the final IServiceCollection
_hostBuilder.ConfigureServices((context, services) =>
{
    // We've only added services configured by the GenericWebHostBuilder and WebHost.ConfigureWebDefaults
    // at this point. HostBuilder news up a new ServiceCollection in HostBuilder.Build() we haven't seen
    // until now, so we cannot clear these services even though some are redundant because
    // we called ConfigureWebHostDefaults on both the _deferredHostBuilder and _hostBuilder.
    foreach (var s in _services)
    {
        services.Add(s);
    }

    // Add any services to the user visible service collection so that they are observable
    // just in case users capture the Services property. Orchard does this to get a "blueprint"
    // of the service collection

    // Drop the reference to the existing collection and set the inner collection
    // to the new one. This allows code that has references to the service collection to still function.
    _services.InnerCollection = services;
});

In the next line we run any callbacks we have collected in the ConfigureHostBuilder property, as I discussed earlier..

If there are any, these include callbacks like ConfigureContainer() and UseServiceProviderFactory(), which are generally only used if you're using a third-party DI container.

// Run the other callbacks on the final host builder
Host.RunDeferredCallbacks(_hostBuilder);

Finally we call _hostBuilder.Build() to build the Host instance, and pass it to a new instance of WebApplication. The call to _hostBuilder.Build() is where all the registered callbacks are invoked.

_builtApplication = new WebApplication(_hostBuilder.Build());

Finally, we have a little bit of house keeping. To keep everything consistent, the ConfigurationManager instance is cleared, and linked to the configuration stored in the WebApplication. Also, the IServiceCollection on WebApplicationBuilder is marked as read only, so attempting to add services after calling WebApplicationBuilder will throw an InvalidOperationException. Lastly, the WebApplication is returned.

// Make builder.Configuration match the final configuration. To do that
// we clear the sources and add the built configuration as a source
((IConfigurationBuilder)Configuration).Sources.Clear();
Configuration.AddConfiguration(_builtApplication.Configuration);

// Mark the service collection as read-only to prevent future modifications
_services.IsReadOnly = true;

return _builtApplication;

That's almost it for the WebApplicationBuilder, but we still haven't executed the ConfigureApplication() callback yet. In the next post, we look at the code behind the WebApplication type, and we'll see where ConfigureApplication() finally gets called.

Summary

In this post we took a look at some of the code behind the new WebApplicationBuilder minimal hosting API. I showed how the ConfigureHostBuilder and ConfigureWebHostBuilder types act as adapters for the generic host types, and how the BootstrapHostBuilder is used as a wrapper around the inner HostBuilder. There was quite a lot of confusing code just to create an instance of the WebApplicationBuilder, but we ended the post by calling Build() to create a WebApplication. In the next post, we'll look at the code behind WebApplication.

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