Skip to content
Kumar Chandrachooda
.NET

BuildServiceProvider Nine Times

Each concern in DShop.Common builds a throwaway DI container just to read config - nine per service, atop three coexisting container frameworks and a vendored config parser.

By Kumar Chandrachooda 22 Oct 2025 6 min read
Nine disposable containers spun up and discarded before the real one exists

The composition root — the one place a program wires its dependencies together — is supposed to be the calmest part of a codebase: a list of registrations, read top to bottom. DShop.Common's is not calm. To register itself, each concern needs to read its section of appsettings.json, and each concern solves that the same way: it builds an entire dependency-injection container, resolves IConfiguration from it, and throws the container away. There are nine of these throwaway containers per service, sitting beneath three different container frameworks that coexist for the app's whole life, next to a copy of Microsoft's config parser the estate pasted in to bridge a gap. This part is about composition-root hygiene, and it is the clearest picture in the series of a good idea — self-registering concerns — accreting cost.

The pattern: build a container to read a file

Here is the shape, verbatim from AddConsul, and it is identical in eight other places:

public static IServiceCollection AddConsul(this IServiceCollection services)
{
    IConfiguration configuration;
    using (var serviceProvider = services.BuildServiceProvider())
    {
        configuration = serviceProvider.GetService<IConfiguration>();
    }

    var options = configuration.GetOptions<ConsulOptions>(ConsulSectionName);
    // ...register everything...
}

services.BuildServiceProvider() compiles the entire service collection registered so far into a working container, just so the next line can pull out IConfiguration, and then disposes it. Grep the library for that call and you get nine hits — AddConsul, AddJwt, AddCustomMvc, AddRedis, AddFabio, UseVault, the Jaeger, Swagger and RestEase registrars. A service that wires up messaging, discovery, persistence, auth, tracing, metrics and docs builds and discards nine containers before its real one is ever constructed. It works — the demo boots — and it is one of the better-known anti-patterns in ASP.NET Core, to the point that the framework's own analyzers warn about calling BuildServiceProvider inside ConfigureServices.

Why does it happen? Because these are extension methods on IServiceCollection, invoked during ConfigureServices, and at that moment there is no built provider to resolve IConfiguration from — the provider is what ConfigureServices is in the middle of describing. The concern needs its config now, to decide what to register, and the only handle it has is the collection. So it builds a provider to peek. The honest fix is right there in the framework idiom: pass IConfiguration in as a parameter — services.AddConsul(Configuration) — the way every modern extension method does, so the concern reads config without conjuring a container. The estate simply predates the habit; this is 2018 code, and the “just take IConfiguration” convention had not yet hardened.

The cost, beyond the smell

Two real hazards hide under the nine builds, not just untidiness.

Captive dependencies. BuildServiceProvider() creates a second root scope. Anything resolved from it is resolved as a root singleton — and if one of these registrars ever resolved a scoped service that way, it would be captured and effectively promoted to a singleton for the life of that throwaway provider, with a fresh copy in the real container later. Here they only pull IConfiguration (a host-level singleton), so the estate skirts the trap — but the pattern is one careless GetService<TScoped>() away from a duplicated-singleton bug that is notoriously hard to see, because both copies “work.” The pattern is safe here by luck of what it resolves, not by design.

Duplicated singletons and startup cost. Each build instantiates every eager singleton registered so far; nine builds at startup is nine passes of that work, thrown away. On a demo it is milliseconds. It is still nine times the container-construction work the app needs, done to read a file the host already parsed.

Three containers, all the way down

Zoom out and the composition is stranger still: a running service hosts three dependency-injection systems at once.

  • Microsoft's IServiceCollection — the ASP.NET Core default, where AddCustomMvc, AddJwt and friends register.
  • Autofac — promoted to the application container, where AddRabbitMq and AddDispatchers register their handlers and dispatchers on a ContainerBuilder (the hand-rolled dispatcher from part eight resolves out of Autofac's IComponentContext).
  • RawRabbit's own internal container — the message broker library builds its own ServiceCollection inside ConfigureBus, where we saw DependencyInjection = ioc => ioc.AddSingleton(...) back in part one.

Three containers is not automatically wrong — Autofac-over-Microsoft-DI was a common and reasonable 2018 setup, and RawRabbit's private container is the library's business. But it means “where is this dependency registered?” has three possible answers, the lifetimes are governed by three sets of rules, and the amnesiac registry from part eleven — whose bug was entirely a lifetime mismatch — lives in exactly this three-container thicket where lifetime reasoning is hardest.

The vendored parser in the corner

One more thing accretes at the composition root, and it is my favourite because it is so honestly labelled. To feed secrets from Vault into IConfiguration, UseVault reads a secret, then flattens its JSON into config keys with a JsonParser — and that parser carries this header:

//Credits goes to  .NET Foundation Team.
//JSON parser is based on JsonConfigurationFileParser found in Microsoft.Extensions.Configuration.Json library.

It is a copy of Microsoft's internal JsonConfigurationFileParser — the class that turns appsettings.json into flat section:key pairs — pasted in because that type is internal and the estate needed the same JSON-to-config-keys flattening for Vault payloads. The attribution is exactly right and does it credit; there is no license problem, and reaching for the known-good algorithm is sensible. But it is vendored framework-internal code: it will never get upstream fixes, it duplicates behaviour the framework owns, and it is one more thing living at the composition root because the composition root is where all the plumbing that “doesn't fit anywhere else” ends up. A config-provider built the framework way (an IConfigurationSource/IConfigurationProvider pair) would have avoided the copy entirely — which is precisely what a mature secrets-into-config integration looks like.

The lesson: the composition root is a place, and places accrete

None of this is a crash. It is a composition root that grew by accretion — each concern solving its config-access problem locally, nobody stepping back to give the whole startup a shape. That is how composition roots always rot: not in one bad decision, but in nine reasonable-looking local ones that never got reconciled. When you find yourself building a container to configure a container, the abstraction you actually want is a parameter. Pass the configuration in; register providers the framework way; keep the lifetimes under one container if you can. When these authors rebuilt this as Convey, that is the change you can feel most — IConfiguration threads through the registration chain as an argument, the BuildServiceProvider-to-read-config dance disappears, and the startup reads like a list again.

One part left, and it is the retrospective the whole series has been walking toward: the rough draft of Convey — the frozen shared-contracts package that became a tombstone, the release-candidate dependency pinned forever, and the concordance of what the framework that followed kept, renamed, and finally fixed.