Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-container-you-build-to-read-a-setting && read --section="top" 0%
Architecture

The Container You Build to Read a Setting

Inflow reads every configuration section by building and disposing an entire service provider - twenty-two of them during startup. Two of those calls do not read anything; they mutate a registry, and they only work because of how three singletons were registered.

By Kumar Chandrachooda 26 Jan 2026 6 min read
A container built, read from, and thrown away - twenty-two times

Part 13 walked a configuration space. This part is about how those settings are read, which turns out to be one of the most instructive twelve lines in the repository.

public static T GetOptions<T>(this IServiceCollection services, string sectionName) where T : new()
{
    using var serviceProvider = services.BuildServiceProvider();
    var configuration = serviceProvider.GetRequiredService<IConfiguration>();
    return configuration.GetOptions<T>(sectionName);
}

Every configuration read in the shared framework builds an entire dependency-injection container over the current ServiceCollection, resolves one service from it, and disposes it. The service being resolved is IConfiguration — which the generic host registered before ConfigureServices was called, and which is available on WebHostBuilderContext, HostBuilderContext, and as a constructor parameter on Startup itself.

This is ASP0000 — “Do not call BuildServiceProvider in ConfigureServices” — committed as a design idiom rather than a slip. It is also, taken alone, a startup-only cost that nobody would notice. The reason it deserves an article is what two of the calls do instead of reading.

Counting them

I traced every path through AddModularInfrastructure and the four modules' AddCore() methods on master. In the shipped four-module configuration, BuildServiceProvider is called twenty-two times during registration.

Nine inside AddModularInfrastructure:

Where Why
Extensions.cs:50 Scan configuration for disabled modules
AddCorsPolicy Read cors
Extensions.cs:79 Read app
AddAuth Read auth
AddMessaging Read messaging
AddSecurity Read security
AddSecurity Resolve a logger, to write one line
AddPostgres Read postgres
AddOutbox Read outbox

Three more per module, times four modules: AddPostgres<T> reads postgres again, AddOutbox<T> reads outbox again, and AddUnitOfWork<T> builds one for a reason we are coming to. Plus one in the Users module for its users:registration section. Nine plus twelve plus one.

Each of those providers is constructed over a ServiceCollection that, by the end of registration, holds several thousand descriptors. Building a provider walks and validates them. It happens once per read, at startup, so the performance argument is weak — a few hundred milliseconds at worst, on a process that runs for weeks. The correctness argument is not weak, and it applies to exactly two of the twenty-two.

The two that are not reading anything

// Messaging/Outbox/Extensions.cs — AddOutbox<T>()
using var serviceProvider = services.BuildServiceProvider();
serviceProvider.GetRequiredService<InboxTypeRegistry>().Register<EfInbox<T>>();
serviceProvider.GetRequiredService<OutboxTypeRegistry>().Register<EfOutbox<T>>();
// Postgres/Extensions.cs — AddUnitOfWork<T>()
using var serviceProvider = services.BuildServiceProvider();
serviceProvider.GetRequiredService<UnitOfWorkTypeRegistry>().Register<T>();

Read those slowly. A container is built. A singleton is resolved from it. That singleton's internal Dictionary<string, Type> is mutated. The container is disposed. And the mutation is expected to survive into the real container that the host builds some milliseconds later.

That should not work. It works because of how the registries were registered, in a different file:

services.AddSingleton(new InboxTypeRegistry());
services.AddSingleton(new OutboxTypeRegistry());
services.AddSingleton(new UnitOfWorkTypeRegistry());

These are instance registrations. The ServiceDescriptor carries ImplementationInstance, not ImplementationType. Any provider built over this collection — throwaway or real — hands back that exact object reference, because there is nothing to construct. The throwaway provider populates the object; the real provider serves the same, already-populated object.

It is deterministic. It is not an accident. The author clearly understood exactly why it works. It is wrong because the reason is invisible, and because the code offers no defence at all for the invariant it depends on.

The one-character failure, and its two shapes

Change services.AddSingleton(new OutboxTypeRegistry()) to services.AddSingleton<OutboxTypeRegistry>(). Same type, same lifetime, same intent as far as any reviewer would read it. The descriptor is now an implementation-type registration.

The throwaway provider constructs instance A. Register<EfOutbox<T>>() populates A's dictionary. using disposes the provider, and A goes with it. Minutes of wall-clock later, the real provider constructs instance B, empty. Every Resolve returns null.

And then the two registries fail differently, which is the whole lesson.

OutboxBroker is loud:

var outboxType = _registry.Resolve(message);
if (outboxType is null)
{
    throw new InvalidOperationException($"Outbox is not registered for module: '{message.GetModuleName()}'.");
}

TransactionalCommandHandlerDecorator is silent:

var unitOfWorkType = _unitOfWorkTypeRegistry.Resolve<T>();
if (unitOfWorkType is null)
{
    await _handler.HandleAsync(command, cancellationToken);
    return;
}

The decorator's response to “I cannot find the unit of work” is to run the handler with no transaction and no log line. Not LogWarning, not LogDebug — nothing. A one-character change to a registration in a file three directories away would turn every command handler in the estate non-transactional, permanently, with zero signal. The system would keep serving traffic, keep returning 200s, and keep writing partial aggregates whenever a handler threw halfway through.

Same idiom, two consumers, and the difference between a page at 3am and a data-integrity incident nobody notices for a quarter is which consumer bothered to guard.

To be fair on two counts. First, TransactionalCommandHandlerDecorator is never registered in the shipped estate — AddTransactionalDecorators() has no callers, as part 9 noted — so the silent path is currently unreachable. Second, AddOutbox<T>() returns early when outbox:enabled is false, before the registry mutation, so in the shipped configuration only AddUnitOfWork<T>'s throwaway container actually mutates anything. The hazard is entirely latent. It is also entirely real for the first person who enables the outbox and tidies a registration in the same sprint.

Two compounding hazards in the same idiom

Registration order is now semantics. AddOutbox<T>() and AddUnitOfWork<T>() are called from each module's AddCore(), which runs after AddModularInfrastructure() has run AddPostgres() and AddOutbox() — the methods that create the registries. Reverse those two statements in Startup.ConfigureServices and GetRequiredService<UnitOfWorkTypeRegistry>() throws at boot. Loud, thankfully, and only because these particular resolves use GetRequiredService. But the dependency is completely implicit: nothing in AddOutbox<T>'s name, signature or documentation says “call AddOutbox() first”.

The registries are mutated from several AddX<T> calls with no synchronisation. Dictionary<string, Type> is not thread-safe for writes. Registration is sequential today. A Parallel.ForEach over modules — a plausible optimisation for an estate with twenty modules and a slow startup — would corrupt them, intermittently, at boot.

And one hazard that is one method call away from being live. AddRedisCache is the only place in the repository that puts an IDisposable into an instance-singleton registration:

services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(options.ConnectionString));

ConnectionMultiplexer.Connect executes at registration time — a blocking, network-bound call inside ConfigureServices, which turns a Redis outage into a startup crash rather than a degraded cache, and cannot be retried or made lazy without editing the line. Then that live multiplexer sits in the descriptor list for every subsequent BuildServiceProvider/dispose cycle. Whether a disposed provider also disposes objects supplied via ImplementationInstance is a container-version detail I have not verified by execution and would not want to depend on. AddRedisCache is never called anywhere in the repository, which is the only reason this is a note rather than a finding. The framework is one AddRedisCache() away from finding out.

The refactoring that removes the whole category

The registries are populated at registration time because that is when the generic parameter T is known. The same information is available at resolution time if the registry is populated by a factory, or by an IHostedService running after Build():

// Fresh illustrative code, not from the repository.
public static IServiceCollection AddOutbox<T>(this IServiceCollection services) where T : DbContext
{
    services.AddTransient<IOutbox, EfOutbox<T>>();
    services.AddTransient<EfOutbox<T>>();
    services.AddSingleton<IOutboxRegistration>(new OutboxRegistration(typeof(EfOutbox<T>)));
    return services;
}

internal sealed class OutboxRegistryInitializer : IHostedService
{
    private readonly OutboxTypeRegistry _registry;
    private readonly IEnumerable<IOutboxRegistration> _registrations;

    // ... constructor ...

    public Task StartAsync(CancellationToken cancellationToken)
    {
        foreach (var registration in _registrations)
        {
            _registry.Register(registration.OutboxType);
        }

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Every AddOutbox<T> call contributes a descriptor; the real container collects them; one hosted service populates the registry once, after Build(), in a place where a missing dependency is a startup exception with a stack trace. Eight BuildServiceProvider calls disappear, the ordering constraint becomes a resolution-time one, and the “must be instance-registered” invariant stops existing.

The general form of the lesson: work done between registration and Build() is work whose correctness depends on facts the container does not enforce. The container's job is to be built once and then be authoritative. Anything that reaches into a pre-Build() provider is reasoning about an object graph that has not happened yet, and every such reach needs a comment explaining why it survives — or, better, a design where it does not need to.

Next, the retrospective: boot loudly, fail quietly — what this framework gets right, where it should not be copied, and the one sentence that describes every failure convention in it.