ASP.NET Core DI Container Pitfalls: Avoid BuildServiceProvider and Lifetime Mismatches
ASP.NET Core dependency injection is easy to start using, but container ownership becomes less obvious during application startup. Calling BuildServiceProvider() while registrations are still being assembled creates a provider that is separate from the provider later built by the host. The code can compile, start, and pass a shallow functional check while singleton state is split, disposal ownership is duplicated, and scoped services are accidentally retained by longer-lived objects. This guide explains a verifiable design for the ASP.NET Core DI container and reports results from a local .NET 9 sandbox.
The core ASP.NET Core DI container rule
An ASP.NET Core DI container should be built and owned by the host. During registration, avoid calling BuildServiceProvider(); use constructor injection, an implementation factory, a hosted lifecycle component, or an explicit scope instead.
IServiceCollection is a collection of service descriptors, not a live container. Every call to BuildServiceProvider() creates a new provider from the descriptors visible at that moment. The ASP.NET Core host subsequently creates its own application provider. Those providers do not share singleton caches, service scopes, disposal tracking, or resolution state even when they were built from the same collection.
The practical review rule is straightforward. If code is still registering services, it should not build a temporary provider merely to retrieve one of those services. That retrieval usually exposes a dependency placed at the wrong architectural stage. Environment information can be supplied to startup composition through supported host mechanisms. A service needed to construct another service can be obtained through an implementation factory. Work that must happen after the host exists belongs in a hosted lifecycle component. Scoped work requires a deliberately created scope.
Microsoft's DI guidance connects these decisions to ownership. A service lifetime defines how long an instance may be reused. The provider owns objects it creates and tracks those that require disposal. Scope validation can reject a scoped service resolved from the root provider or captured by a singleton. These are observable runtime semantics rather than stylistic preferences.
This topic is narrower than general ASP.NET Core architecture. It answers a specific search intent: why a temporary provider creates duplicate singleton behavior, how lifetime mismatches arise, and how a developer can reproduce both conditions in a disposable test project.
Why BuildServiceProvider creates another singleton world
Each provider maintains its own singleton cache, so a singleton is one instance per provider rather than one instance for the entire process. Building two providers therefore creates two independent instances after each provider resolves that service.
Suppose the collection contains AddSingleton(). A temporary provider resolves instance A. The host provider later resolves instance B. Warming A does not warm B. If the type owns an in-memory cache, a work queue, event subscriptions, or mutable coordination state, the two instances can produce inconsistent observations without throwing an immediate exception.
Registration timing adds another failure mode. A provider is a snapshot of the descriptors used to build it. Services added to the collection afterward do not appear retroactively in the old provider. Code that converts the resulting resolution failure into a null value or fallback can hide what should have been an early composition error and move it into request handling.
Disposal is also scoped to provider ownership. A provider tracks disposable instances that it creates. Two providers can own two singleton instances and require two separate disposal paths. If a temporary provider is never disposed, its tracked objects can remain alive longer than intended. If it is disposed while one of its services has escaped into application state, that escaped service can become unusable too early. Adding increasingly complex disposal code treats the symptom; removing the unnecessary provider fixes the ownership boundary.
A useful review starts with a repository search for BuildServiceProvider(. For every match, ask whether registrations are still being composed, why an implementation factory cannot receive the provider, and who owns the resolved object. A value stored in a static field, global cache, or cross-request coordinator is a strong signal that the design needs to be rewritten before it is merged.
The API itself remains valid for controlled scenarios such as an isolated container test. The problem is not that the method can never be called. The problem is calling it inside application service configuration and then treating the resulting provider as though it were the host-owned provider.
Transient, scoped, and singleton ownership boundaries
Choose a lifetime according to the real owner of the state: transient for short independent work, scoped for state shared inside one explicit operation scope, and singleton only for dependencies that can safely live just as long.
A transient service is normally created each time it is requested. This suits small stateless transformers and lightweight coordinators, but transient does not mean disposal is irrelevant. When a disposable transient is resolved by a container, the container can track it until that provider or scope ends. Repeatedly resolving disposable transients from the root provider may retain them much longer than a caller expects.
A scoped service means one instance per IServiceScope. In an HTTP application, one request normally supplies that scope, but the broader definition matters. A background worker does not automatically receive an HTTP request scope. It should use IServiceScopeFactory.CreateScope() or CreateAsyncScope() for each unit of work, resolve scoped dependencies inside that boundary, and dispose the scope when the operation finishes.
A singleton can remain alive until its provider is disposed and can be called concurrently. Its dependency graph must therefore avoid shorter-lived scoped instances. A singleton that directly receives a scoped service promotes that service beyond its intended boundary, a condition commonly called a captive dependency. If a singleton coordinates work that needs scoped dependencies, it should retain an IServiceScopeFactory and create a short scope inside the operation rather than store the scoped service in a field.
ValidateScopes = true checks important lifetime rules during resolution. ValidateOnBuild = true asks the container to validate service descriptors when the provider is built where possible. Enabling both in integration tests or focused composition tests turns many lifetime mistakes into deterministic failures. The options do not prove that all global state is thread-safe or that every service is correctly designed, but they detect a high-value category of root and scope misuse.
A clear test can also verify scope semantics directly. Resolve a scoped marker twice from one scope and once from a second scope. The first two references should be identical, while the reference from the second scope should differ. That small test documents the intended boundary more precisely than a comment saying that a service is request based.
Refactoring without a temporary provider
Refactor resolution into the provider owned by the host: use factories for synchronous object composition, hosted services for startup work, method-level injection where supported, and scope factories only at genuine scope boundaries.
The first common need is constructing service B with service A. Register B through an implementation factory such as services.AddSingleton(sp => new B(sp.GetRequiredService())). The sp supplied to that factory belongs to the final provider, so both objects participate in the intended lifetime and disposal graph. Keep the factory small and synchronous. Network access, data preparation, or long-running initialization does not belong in object construction.
The second need is initialization after the host has been built. Put that work in IHostedService or BackgroundService, which gives the host control over start, stop, and cancellation. If initialization needs scoped services, create a scope inside the lifecycle method. Do not launch an asynchronous method from registration without awaiting it. Calling ConfigureAwait(false) only configures how continuation is scheduled after an await; the call itself does not wait for completion.
The third need appears in middleware. Singleton dependencies can normally enter conventional middleware through its constructor, while scoped dependencies should be requested for each invocation through supported method parameters or a per-operation scope. Resolving a scoped object from app.ApplicationServices and storing it in the middleware merely recreates the lifetime mismatch under a different spelling.
The following pattern keeps scope creation in the cross-scope coordinator while ordinary domain services continue using constructor injection:
services.AddScoped();
services.AddSingleton(sp =>
new Coordinator(sp.GetRequiredService()));
sealed class Coordinator(IServiceScopeFactory scopeFactory)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var work = scope.ServiceProvider.GetRequiredService();
await work.ExecuteAsync(cancellationToken);
}
}
This is not an invitation to pass IServiceProvider through every class. The scope factory stays at the component that truly crosses scope boundaries. Services inside the scope receive their concrete dependencies through constructors, keeping the dependency graph visible and testable.
When removing an existing temporary provider, inventory every service resolved from it before deleting the call. Identify each lifetime, where the object is stored, whether it starts work, and who currently disposes it. Move synchronous composition to factories, lifecycle work to a hosted component, and scoped operations to explicit scopes. Then remove the old provider and add validation tests. This sequence avoids silently discarding initialization that had been hidden inside configuration.
Verification and reproduction with .NET 9
The scheduler executed a disposable .NET 9 console test for three claims: exact SDK selection, distinct singleton instances from two providers, and scope validation rejecting a singleton that captures a scoped dependency.
The prerequisite is .NET SDK 9.0.100. A global.json pins that exact version with roll-forward disabled, so a machine lacking the version fails immediately instead of selecting a different SDK. The project targets net9.0, references the shared ASP.NET Core framework for the DI implementation, creates only in-memory objects, and does not connect to any external system or alter existing data.
- Create an empty test directory with
global.jsonselecting version9.0.100androllForwardset todisable; rundotnet --version. Pass only when standard output is exactly9.0.100. - Build the Release configuration and run a program that creates two providers from one service collection. Resolve the same registered singleton type from each. Pass only when the program prints
DISTINCT_SINGLETONS=True. - Register a scoped type and a singleton whose constructor requires that scoped type. Build with
ValidateScopesandValidateOnBuild. Pass only when the expected exception is caught and the program printsSCOPE_VALIDATION_CAUGHT=True.
dotnet --version
dotnet build DiContainerCheck.csproj -c Release --nologo
dotnet run --project DiContainerCheck.csproj -c Release --no-build
The observed scheduler result selected SDK 9.0.100. The Release build completed with zero warnings and zero errors. Runtime output contained both DISTINCT_SINGLETONS=True and SCOPE_VALIDATION_CAUGHT=True. These observations support only the stated container and validation behavior under the documented prerequisites. They are not a benchmark and make no claim about throughput, latency, traffic, or reliability in another application.
The pass and fail rules are deliberately closed. A different SDK version fails the prerequisite. A nonzero build exit code fails before runtime markers are interpreted. A missing marker or a value of False fails the corresponding claim. Merely reaching the end of the command is not sufficient evidence.
Readers should keep the version pin when reproducing the result. To evaluate another runtime, change the SDK selection and target framework explicitly, record the new environment, and rerun all three checks rather than assuming the behavior from this test is a substitute for verification.
Code review checklist before merge
A DI review should examine provider count, lifetime direction, scope creation, asynchronous initialization, and disposal ownership together. Searching for one API catches a useful symptom but does not cover every captive dependency.
- Service registration contains no temporary
BuildServiceProvider()call and no provider is stored in a static field. - A singleton's constructor dependency graph contains no scoped service. Short-lived work is resolved inside a method-level scope.
- Each background operation creates and disposes its own scope rather than retaining an object from an expired HTTP request.
- Implementation factories perform synchronous composition only. Awaitable initialization is owned by a hosted lifecycle component and supports cancellation.
- Integration tests build the provider with
ValidateScopesandValidateOnBuild, and any lifetime exception fails the test. - Objects created by the container are disposed by the container. Objects created manually have an explicit and reviewable owner.
- Middleware receives scoped services per invocation instead of retaining them in a long-lived constructor or global field.
- Test evidence names the fixed SDK, complete commands, expected markers, and explicit failure conditions without unsupported performance language.
If a codebase already has temporary providers, fix the architecture in a controlled order. Map current resolutions, move each one to its correct host-owned boundary, enable validation, and only then remove compatibility paths. A direct deletion without that inventory can remove work that was incorrectly but materially happening during registration.
The review should also reject a replacement that hides service location behind a helper. Renaming GetRequiredService or wrapping a root provider in a global accessor does not restore lifetime correctness. The dependency should remain visible at the constructor or at the narrow scope boundary where it is genuinely needed.
Conclusion: make container ownership a testable contract
One host-owned provider, valid lifetime direction, and explicit scopes form the practical contract of ASP.NET Core DI. Fixed-version tests make that contract enforceable whenever startup composition changes.
The BuildServiceProvider() pitfall is confusing because it often remains silent in ordinary feature tests. Once singleton is understood as one instance per provider, duplicate state is no longer mysterious. Implementation factories let the final provider perform composition, hosted services own initialization, and scope factories delimit shorter-lived work.
Keep the evidence concrete: an exact SDK, a clean build, a marker proving singleton instances differ across providers, and an expected validation failure for a captive dependency. Those signals are stronger than saying that startup appeared normal, and they can be reproduced without an actual account, external data, irreversible operations, or fabricated operational results.