.NET Core DI Lifetime Boundaries for Microservices: Scoped, Singleton, and Captive Dependency Control
Use .NET Core DI lifetimes as trust boundaries. Keep DbContext Scoped per request, avoid captive dependencies with Singleton services, and create explicit scopes for background work. Validate with ValidateScopes and scope identity tests.
15 Feb 2026, 06:53 UTC

The problem is captive dependencies across service boundaries
In a .NET Core microservice the built-in DI container is the boundary enforcer. The useful takeaway is to treat lifetime as a trust boundary: a Singleton must never hold a Scoped service, and a Scoped service must never be resolved outside an explicit IServiceScope. When that rule is broken you get disposed-object use or state leaking between requests, not a compile-time error.
Requirements for lifetime management in a request-based service
A typical web API microservice needs three guarantees.
- Per-request transactional consistency for EF Core DbContext or similar unit-of-work.
- Isolation between concurrent requests so no mutable state is shared unintentionally.
- Deterministic disposal of IDisposable services when a request ends or the process stops.
The built-in container provides three lifetimes to satisfy those guarantees: Transient, Scoped, and Singleton. Transient creates a new instance per resolution. Scoped creates one instance per IServiceScope, which in ASP.NET Core maps to one HTTP request. Singleton creates one instance for the application lifetime.
Smallest suitable design
Keep registration explicit and co-locate lifetime decisions with the abstraction they protect.
// Program.cs, run as the application host with permission to modify services
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(...), ServiceLifetime.Scoped);
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddSingleton<ICache, DistributedCache>();
builder.Services.AddTransient<ITransientProcessor, TransientProcessor>();
DbContext and UnitOfWork are Scoped so the same instance is reused within one request and disposed when the request scope ends. The container automatically calls Dispose on services it created when the scope ends.
For background work where no HTTP scope exists, create a scope manually:
// In a hosted service, run with application identity
using var scope = _services.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
// use processor, dispose happens at end of using
Risk: resolving a Scoped service from the root provider throws when ValidateScopes is enabled and causes captive dependency otherwise.
Trust and data boundaries
Data boundaries are maintained by interfaces and scope isolation, not by the container alone.
Interface boundary
Expose IRepository or IDomainService to application code. The concrete implementation can be swapped without changing consumers. This keeps persistence details out of the business logic layer.
Scope boundary
IServiceScope is the trust boundary for per-request state. Anything registered Scoped must only be consumed by Transient or Scoped consumers. A Singleton consumer must never capture a Scoped service, even via a factory that is called lazily.
Captive dependency is the failure mode: a longer-lived service holds a reference to a shorter-lived service. The shorter-lived instance is disposed while the longer-lived holder still uses it, leading to ObjectDisposedException or stale state.
Operational checks
Validate lifetime behavior in development and CI.
- Enable scope validation. In Program.cs set HostOptions.ValidateScopes to true during development. This causes the container to throw at resolution time if a Singleton depends on a Scoped service.
- Unit test scope identity. Create two scopes and assert that a Scoped service returns the same instance within one scope and a different instance across scopes. This verifies the container wiring without starting a web server.
- Log scope creation and disposal for background workers. Add logging around CreateScope and Dispose to detect leaked scopes in long-running tasks.
Singleton services must be thread-safe because they are shared across all concurrent requests in the process. Use immutable state or synchronization for mutable state.
Failure modes
Scoped service used in background task without manual scope. The service is resolved from the root provider and will be disposed prematurely or never disposed.
Singleton holding a DbContext. The context is created once and reused across requests, breaking change tracking and connection pooling assumptions.
Transient service capturing a Scoped service via a closure. The captured reference outlives the request scope.
All three are silent in production unless ValidateScopes is on and tests cover scope boundaries.
When the design must change
The smallest design holds until one of these conditions appears.
- Out-of-process service location is required. The built-in container is in-process only. For distributed service location you need a separate orchestration layer.
- Request scope no longer maps to a single logical unit of work, e.g., saga orchestration spanning multiple requests. Then explicit scope factories and ambient context must be introduced.
- Need for deterministic disposal across process boundaries. The container only disposes when the scope ends in-process.
Limitation: the built-in container does not support decorator chaining or named registrations natively. If those are required, keep the built-in container for core lifetimes and introduce a thin factory abstraction rather than replacing the container.
Check the result by running the application with ValidateScopes enabled and by inspecting logs for scope disposal counts matching request counts. If counts diverge, a scope leak exists.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.