Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/building-a-second-container-to-read-one-value && read --section="top" 0%
.NET

Building a Second Container to Read One Value

services.BuildServiceProvider() inside ConfigureServices - what it actually costs, why it does not leak here, and how a zero-parameter extension method signature forced it.

By Kumar Chandrachooda 18 Feb 2026 7 min read
A solid container with a second one drifting away as a dashed outline, one value lifted out

There is a moment in every configuration-driven library where the author needs the configuration before the container exists. The library's job starts at ConfigureServices; the thing it must read to do that job — the YAML, the connection string, the module list — lives in IConfiguration, which is registered in that same collection and not yet resolvable. Every library solves this. Most solve it by taking a parameter. A few solve it by building a container.

Part 9 finished the auth arc. This part turns to the composition root, and to one line that is by now a well-known .NET anti-pattern — but which was not, when it was written, and whose real cause is not where you would expect.

The line

private static (NtradaOptions, OptionsProvider) BuildConfiguration(IServiceCollection services)
{
    IConfiguration config;
    using (var scope = services.BuildServiceProvider().CreateScope())
    {
        config = scope.ServiceProvider.GetService<IConfiguration>();
    }

    var optionsProvider = new OptionsProvider(config);
    services.AddSingleton<IOptionsProvider>(optionsProvider);
    var options = optionsProvider.Get<NtradaOptions>();
    services.AddSingleton(options);

    return (options, optionsProvider);
}

NtradaExtensions.cs:53-67

Four questions, answered precisely, because vague disapproval of this pattern is more common than an accounting of what it costs.

Does a second container actually get built? Yes. ServiceCollection.BuildServiceProvider() constructs a complete ServiceProvider, including a CallSiteFactory built over every ServiceDescriptor registered so far. AddNtrada runs inside Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(...), so “so far” means the entire default web host: hosting, options, logging, routing, Kestrel, the diagnostics stack. That is on the order of a hundred-plus descriptors. It is a real, fully functional, second root container, and it is constructed to answer one question.

What does the using wrap? The scope, not the provider. services.BuildServiceProvider().CreateScope() returns an IServiceScope; disposing it disposes the scope's disposables. The root ServiceProvider is never assigned to a variable, never disposed, and becomes unreachable garbage the instant the statement completes. Any IDisposable singleton that provider had constructed would leak — that is the classic hazard, and it is why the pattern earned its reputation.

Does it leak here? No — and the reason is informed luck. Exactly one service is resolved, IConfiguration, and HostBuilder.Build() registers it as AddSingleton<IConfiguration>(_appConfiguration) — a pre-built instance registration. Instance registrations are handed back as-is; the container constructs nothing and takes ownership of nothing. So no duplicate configuration object exists and nothing disposable is orphaned. Resolve anything else through that provider — a factory-registered singleton, a logger provider, IHttpClientFactory — and you get a second instance the real container never sees, plus an undisposed graph.

Is the scope necessary? No. IConfiguration is a singleton; provider.GetService<IConfiguration>() on the root would do. CreateScope() allocates an engine scope and a disposal registry for nothing, and it signals to a reader that scoping matters here when it does not.

The 2019 verdict, fairly stated

ASP0000 — Do not call 'BuildServiceProvider' from application code shipped with the .NET 5 SDK analysers, roughly a year after this code was written. In netcoreapp3.1 there was no tooling signal at all. The pattern appears in a great deal of 2018–2019 community library code and in more than a few Microsoft samples of the period, precisely because the problem is genuine and the alternative required a conscious API decision. Judging this line by an analyser that did not exist is hindsight, and it is not the interesting criticism.

The interesting criticism is what caused it. Look at the host:

.ConfigureServices(services => services.AddNtrada())
.Configure(app => app.UseNtrada());

src/Ntrada.Host/Program.cs:25-26

AddNtrada() takes no parameters. That is a deliberate, defensible, README-driven choice: two lines, no arguments, nothing for a user to get wrong. It is also the reason BuildConfiguration exists, because the two-parameter overload was one lambda parameter away in the same file:

// available in 3.1, and in this very repository
.ConfigureServices((context, services) => services.AddNtrada(context.Configuration))

The API-surface aesthetic drove the implementation compromise. AddNtrada() reads beautifully in a README; AddNtrada(configuration) reads almost as well and needs no second container. That trade — a nicer call site paid for with a hidden hundred-descriptor container build — is the kind of decision worth making explicitly, and there is no sign it was made explicitly. The sample's own Startup class even has the injection point available and unused: its constructor could take IConfiguration, which is what dotnet new web scaffolds.

The compromise propagates into three types

This is the part that makes the line worth an article rather than a footnote. Because IConfiguration had to be obtained before the container existed, three services could not be registered as types — only as pre-built instances:

services.AddSingleton<IOptionsProvider>(optionsProvider);   // line 62
services.AddSingleton(options);                             // line 64
services.AddSingleton<IExtensionProvider>(extensionProvider); // line 153

An instance registration means the container never constructs the object, which means the object can never take a dependency. OptionsProvider can never accept an ILogger. ExtensionProvider can never accept an ILogger<ExtensionProvider> — which is exactly why extension loading logs from NtradaExtensions.UseExtensions at line 285, in a static class, about work that happened somewhere else:

logger.LogInformation($"Enabled extension: '{extension.Extension.Name}' " +
                      $"({extension.Extension.Description}){orderMessage}");

The provider that did the discovery cannot report on it, so the caller reports on its behalf. That is a small deformation, but it is a shape deformation, and it was caused by a decision three method calls away. One shortcut at the root propagated into the constructors of three types and the log site of a fourth.

There is a second-order effect too. AddExtensions binds the configuration a second time:

private static IServiceCollection AddExtensions(this IServiceCollection services, IOptionsProvider optionsProvider)
{
    var options = optionsProvider.Get<NtradaOptions>();
    var extensionProvider = new ExtensionProvider(options);

NtradaExtensions.cs:149-152

Get<NtradaOptions>() does new T() plus _configuration.Bind(options) — a fresh object, bound afresh. So ExtensionProvider holds a different NtradaOptions instance from the singleton every other component receives. The entire YAML tree is reflected over twice at startup, which is trivially cheap, and the two copies can diverge, which is not. ConfigurePayloads normalises PayloadsFolder on the singleton (NtradaExtensions.cs:108-122) and not on the extension provider's copy. Harmless today, because ExtensionProvider reads only Extensions — and a genuine two-sources-of-truth defect the day anyone normalises something an extension reads. The already-bound object was in scope on line 43 and could simply have been passed.

The optional feature's mandatory tax

One more line from the same method deserves an accounting, because it is the composition root's second-largest cost and it has the same cause — a decision made for one consumer, paid for by all of them:

private static IServiceCollection AddCoreServices(this IServiceCollection services)
{
    services.AddMvcCore()
        .AddNewtonsoftJson(o => o.SerializerSettings.Formatting = Formatting.Indented)
        .AddApiExplorer();

    return services;
}

NtradaExtensions.cs:69-76

Ntrada has no controllers. It has no action filters, no model binders, no view engine, no [ApiController] anywhere, and it never resolves anything from MVC's services on any code path. AddMvcCore() registers the whole MVC application-model graph regardless — application parts, the controller activator, the action descriptor collection provider, the result executors — and AddApiExplorer() adds IApiDescriptionGroupCollectionProvider on top.

That last type is the reason all of it is there. The optional Swagger extension needs it to produce a document. So every deployment of the gateway, including the ones with no Swagger key in their YAML, pays MVC's registration and startup cost for a feature they did not enable. And the serialiser configured in the middle of the chain — Formatting.Indented — configures MVC's serialiser, which as part 6 noted is used by nothing Ntrada emits.

To be fair, AddApiExplorer() in 3.1 genuinely required the MVC core services; there was no lighter path to the API description provider, and endpoint metadata was not yet a rich enough substitute. The available alternative was for the Swagger extension to call AddMvcCore().AddApiExplorer() itself, inside its own IExtension.Add — which is precisely what the extension model exists for. The tax is not that MVC is expensive; it is that an optional package's dependency was hoisted into the mandatory core.

The private class that exists to be a generic argument

One delight before we leave this file, because reading composition roots is otherwise a joyless activity:

private class Ntrada
{
}

NtradaExtensions.cs:290-292

An empty private nested class, whose entire purpose is to be the T in ILogger<Ntrada> so that a static extension class can obtain a logger with a sensible category name. NtradaExtensions is static, so ILogger<NtradaExtensions> would work but would print the extension class's name in every log line; ILoggerFactory.CreateLogger("Ntrada") would also work and is the intended API. Instead there is a type that exists only to be named. It compiles to nothing at runtime beyond a type handle, it costs nothing, and it is a perfectly reasonable trick — but it is the sort of thing you only find by reading, and it tells you something true about the author's instincts: reach for the type system first, even for a string.

What the fix looks like

The whole cluster resolves with Add Parameter:

public static IServiceCollection AddNtrada(this IServiceCollection services, IConfiguration configuration)
{
    var optionsProvider = new OptionsProvider(configuration);
    var options = optionsProvider.Get<NtradaOptions>();
    services.AddSingleton<IOptionsProvider>(optionsProvider);
    services.AddSingleton(options);

    return services.AddCoreServices()
        .ConfigureLogging(options)
        .ConfigureHttpClient(options)
        .ConfigurePayloads(options)
        .AddNtradaServices()
        .AddExtensions(optionsProvider, options);
}

No second container. No double bind. options passed once to the one place that needed it. The README gains six characters. When an implementation is contorting to preserve a signature, the signature is usually the thing to change — and the tell, every time, is a private method whose name describes plumbing rather than domain work.

Next, everything is a singleton — twenty-two descriptors, no scopes at all, and why half of that is the right call.