An ASP.NET Core application behaves correctly in a quick manual test, then starts returning stale data or throwing disposed-object exceptions under real traffic. Its service registrations compile, its constructors look reasonable, and each individual service appears to work.

The missing question is often ownership: who keeps this object alive, who shares it, and when does its work actually finish?

Introduction

Dependency injection removes much of the manual work of constructing services. It does not remove the need to design their lifetimes. A service can still retain request data for too long, share a dependency that is unsafe for concurrent use, or start work that continues after its dependencies are disposed.

These problems are difficult to recognise because the registration that looks wrong is not always the registration that caused the symptom. A singleton dashboard might retain a repository, which retains an EF Core context, which tracks entities from an earlier request.

The resulting exception may appear deep inside a query or during response serialisation. Correcting only that last method leaves the ownership error in place.

This article follows request handlers, background jobs and shared services through their dependency graphs. It explains how to identify captive dependencies, choose scopes deliberately, handle disposal and validate the design with realistic overlapping work.

Start With the Three Lifetimes

The built-in .NET dependency injection container supports three common lifetimes:

  • A transient registration creates an instance each time that service is resolved.
  • A scoped registration reuses an instance within a dependency injection scope.
  • A singleton registration reuses an instance for the lifetime of the root provider.

In an ordinary ASP.NET Core application, the framework creates a scope for each HTTP request. Two services resolved within that request can therefore share the same scoped repository or database context.

The next request receives a different scoped instance. This is useful when several operations form one request-level unit of work.

A singleton is shared across requests and can be called concurrently. Its lifetime does not imply that the container serialises access to it. Its fields and the dependencies it uses must support the intended concurrency.

A transient is not necessarily short lived. It is created on resolution, but the object that receives it can retain it indefinitely. If a singleton captures a transient in its constructor, that particular transient can remain reachable for the singleton's entire lifetime.

Microsoft's dependency injection overview explains the container's creation and scope rules. The practical review question is how those rules interact with ordinary object references.

A Scope Is an Ownership Boundary

A scope is not a thread. An asynchronous method can resume on a different thread and still use the same request-scoped services.

Calling Task.Run does not create a new scope. Starting several tasks does not give each task a separate database context. They keep the object references passed to or captured by their delegates.

A scope is also not automatically a transaction. Two services can share a scoped context while performing several separate saves. Transaction boundaries still depend on the database operations and explicit transaction design.

Likewise, creating a second scope does not create a child request that safely inherits all ambient state. Built-in container scopes are independent ownership boundaries, not a hierarchy through which scoped instances are automatically inherited.

Imagine a request that starts two parallel operations using the same injected repository. Both operations are inside the same request scope, so they may share the same context. The scope is valid, but concurrent access can still be invalid.

Separating these ideas prevents a common misunderstanding: correct lifetime ownership is necessary, but it does not by itself establish thread safety, data isolation or transactional consistency.

Recognise a Captive Dependency

Consider a dashboard service that stores its repository in a field:

builder.Services.AddScoped<OrderRepository>();
builder.Services.AddSingleton<OrderDashboard>();

public sealed class OrderDashboard(OrderRepository repository)
{
public Task<int> CountOpenOrdersAsync(CancellationToken token)
=> repository.CountOpenOrdersAsync(token);
}

The dashboard is shared for the application's lifetime, but its repository is intended to belong to a scope. The longer-lived object captures a shorter-lived dependency.

If the repository uses a scoped EF Core context, the dashboard also captures that context indirectly. The complete dependency graph matters, even if the singleton constructor never mentions DbContext.

With scope validation enabled, the container can reject this graph. Without it, resolving a scoped dependency through the root can effectively extend its ownership to the root provider. That can turn request-oriented state into application-wide state.

Symptoms might include unexpectedly persistent tracked entities, concurrent-operation exceptions, memory growth or behaviour that changes depending on which request ran first.

Changing the repository to transient is not an automatic correction. The dashboard still retains the specific repository it received. If that repository is stateful or retains another scoped service, the underlying design problem remains.

Choose the Correction From the Responsibility

If the dashboard performs request-specific work, make it scoped:

builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<OrderDashboard>();

This preserves the natural request ownership boundary. It does not require a service locator or a new scope hidden inside every method.

If the dashboard is genuinely a shared cache, separate the cache from the component that queries request-owned data. The shared part can hold immutable result snapshots, while a scoped loader queries and supplies fresh values.

If a long-lived coordinator must perform independent jobs, it can create an explicit scope for each job. That is a different responsibility from sharing one request repository forever.

The key is to decide whether the service represents request work, shared state or background orchestration. A lifetime change should express that decision instead of merely suppressing validation.

Trace Dependencies Beyond the First Constructor

A lifetime review should follow the whole graph. A singleton may depend on a formatter, which depends on a tenant resolver, which depends on request-specific data.

The formatter's innocent name does not make the graph safe. If it stores the tenant identifier from its first resolution, later callers can receive values formatted for the wrong tenant.

Review fields and caches as well as constructor parameters. A service may receive a safe factory but call it once and store the returned scoped object. A delegate can capture a controller even though no singleton constructor directly mentions that controller.

Static fields create another retention route. Assigning a request service to a static cache bypasses the lifetime registrations entirely. The container cannot prevent ordinary application code from keeping references after their intended scope ends.

Factory registrations deserve particular attention:

builder.Services.AddSingleton<PriceCatalogue>(provider =>
{
var repository = provider.GetRequiredService<PriceRepository>();
return new PriceCatalogue(repository);
});

If PriceRepository is scoped, the factory still resolves it for a singleton. A factory is not a special exemption from ownership rules.

Dynamic factories can also hide dependencies from build-time validation. The fact that the application starts successfully does not prove every later resolution path is safe.

Give Background Work Its Own Scope

A hosted background service is long lived. It should not accept a scoped job handler directly in its constructor and retain it for the entire process lifetime.

Instead, inject IServiceScopeFactory and create a scope for each independent unit of work. Resolve the job handler inside that scope, await completion, then dispose the scope.

public sealed class InvoiceWorker(
IServiceScopeFactory scopeFactory,
InvoiceQueue queue,
ILogger<InvoiceWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
await foreach (var job in queue.ReadAllAsync(stoppingToken))
{
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider
.GetRequiredService<InvoiceJobHandler>();

await handler.ProcessAsync(job.Id, stoppingToken);
logger.LogInformation("Processed invoice job {JobId}", job.Id);
}
}
}

This is an illustrative sequential worker. InvoiceQueue represents an application-specific queue abstraction, and production failure handling depends on how jobs are acknowledged and retried.

Register the handler as scoped and the worker through AddHostedService. Microsoft's scoped background service example demonstrates the same ownership boundary.

Using CreateAsyncScope with await using supports asynchronous disposal when dependencies implement IAsyncDisposable. The scope remains alive until the awaited work completes.

Make the Scope Match the Job

A scope per job is usually easier to reason about than a scope held for the worker's entire lifetime. It prevents one job's tracked entities or mutable service state from accumulating across unrelated jobs.

A scope per individual database call can be too narrow if several operations are intended to share a unit of work. Define the business operation first, then choose the scope that owns its dependencies.

If jobs run concurrently, each independent job should have its own scope. A single scope around a parallel batch can reintroduce shared contexts and state.

Limit the number of concurrent jobs according to capacity. Correct scopes can create independent contexts, but the database still has finite connection and execution capacity.

Do not store the resolved handler in a worker field after disposing the scope. Store the scope factory and stable coordination services; acquire job-specific services only for the duration of each job.

Avoid Capturing Request Services in Queued Work

An endpoint might try to respond quickly by starting invoice generation in the background:

_ = Task.Run(() => invoiceService.GenerateAsync(orderId));
return Results.Accepted();

If invoiceService is scoped to the request, the response can finish and dispose its dependencies while invoice generation is still running. Even when the timing happens to work, the task has weak ownership and failure reporting.

Queue a stable payload instead. An order identifier, tenant identifier and operation identifier can describe the requested work without retaining the controller, request scope or database context.

The worker then creates its own scope, loads the required data and rechecks the permissions or business constraints appropriate to the background operation. A durable queue may be required when work must survive process restarts.

Be careful with cancellation. The request's cancellation token describes the client's request lifetime. A durable job accepted for later execution usually needs a different execution token tied to worker shutdown or explicit job cancellation.

That distinction should be part of the product contract. Cancelling a browser request after a job is durably accepted does not necessarily mean the job should disappear.

Await Work Before Disposing Its Scope

One particularly subtle error is returning an unfinished task from inside a scope:

public Task BuildReportAsync(CancellationToken token)
{
using var scope = scopeFactory.CreateScope();
var builder = scope.ServiceProvider
.GetRequiredService<ReportBuilder>();

return builder.BuildAsync(token);
}

The method returns the task and exits its using block. The scope can be disposed before the asynchronous report operation finishes.

The correction is to await the operation while the scope still exists:

public async Task BuildReportAsync(CancellationToken token)
{
await using var scope = scopeFactory.CreateAsyncScope();
var builder = scope.ServiceProvider
.GetRequiredService<ReportBuilder>();

await builder.BuildAsync(token);
}

This is not about preferring a coding style. The await changes the ownership interval: disposal now happens after completion, failure or cancellation of the operation.

The same issue applies to asynchronous enumerables and lazy results. Returning a query or stream that still needs scoped services can move execution outside the scope that owns them.

Either materialise the required result while the scope is valid, or design the enumerator and caller so ownership remains alive for the complete enumeration. Do not assume returning an object means all work needed to produce its data has finished.

Keep Disposal With the Owner

Normally, the container disposes disposable services it creates when their owning scope or provider ends. A consumer should not independently dispose an injected dependency that other consumers may still use.

For example, wrapping an injected repository in using can dispose the shared request context halfway through the request. A later service then fails even though it never disposed anything itself.

Objects supplied directly through instance registration have different ownership:

var transport = new CustomTransport();
builder.Services.AddSingleton(transport);

The application created this object. Its disposal responsibility must be handled deliberately rather than assumed to follow the same creation path as container-owned instances.

Factories that manually create disposable resources need equally clear contracts. A factory can return an explicitly caller-owned object, but callers must know they are responsible for disposing it.

Disposable transient services deserve care when resolved repeatedly from the root. The built-in container can retain them for disposal at provider shutdown, extending their retention much longer than the code author intended.

Microsoft's dependency injection guidelines explain these ownership distinctions. The safest everyday rule is to identify one clear owner for each disposable resource and align that owner's lifetime with the work.

Understand the Special Risk Around DbContext

EF Core contexts combine several responsibilities: database access, change tracking and a unit-of-work boundary. They are not general shared caches.

The usual AddDbContext registration is scoped, which fits many HTTP request handlers. Multiple services in that request can participate in one context, provided they use it sequentially.

EF Core does not support concurrent operations on the same context. Two independent queries launched together through the same scoped repository can therefore fail even though all registrations are valid.

Microsoft's DbContext lifetime and threading guidance requires awaiting operations or using separate context instances for parallel work.

When several independent units of work must occur within a longer-lived component, IDbContextFactory<TContext> can make context creation explicit. The caller disposes each factory-created context after its unit of work.

public async Task<int> CountOpenAsync(CancellationToken token)
{
await using var db = await contextFactory
.CreateDbContextAsync(token);

return await db.Orders.CountAsync(
order => order.Status == OrderStatus.Open, token);
}

A factory does not make arbitrary downstream dependencies safe to share. Review how tenant settings, connection selection and any scoped collaborators are supplied. It is an ownership tool with a specific purpose, not a universal repair for an invalid graph.

Keep Singleton State Safe to Share

A singleton can be appropriate for an immutable configuration reader, a shared cache or a stateless coordinator. It becomes dangerous when fields quietly hold per-request data.

Consider a CurrentCustomerId property set at the start of each request. Two overlapping requests can overwrite it. A later query might use the other customer's identifier.

The bug is not fixed by making the setter thread safe. Even if each individual read and write is protected, the shared variable still represents several independent requests incorrectly.

Pass request-specific values as method parameters or keep the containing service scoped. Immutable inputs make ownership easier to see than hidden mutable context.

For a genuine shared cache, define synchronisation, eviction and value ownership. A thread-safe dictionary protects its own operations, but it does not make mutable objects stored inside it thread safe.

Prefer cached data snapshots to cached service instances. A result record containing product prices is easier to share safely than a repository holding a database connection and tracking state.

Treat Options and HTTP Clients Deliberately

Options services have lifetime semantics too. IOptionsSnapshot<T> is scoped and is intended for scoped or transient consumers; capturing it inside a singleton conflicts with that lifetime.

A singleton that needs updated configuration can often use IOptionsMonitor<T>, while explicitly deciding whether to read current values per operation or react to changes. Microsoft's options pattern documentation describes the differences.

Do not copy mutable options into shared fields without considering concurrent changes. If an operation needs a consistent configuration snapshot, capture the required values at that operation's start.

HTTP client ownership is another common source of misleading fixes. A typed client registered through IHttpClientFactory can be retained by a singleton, but doing so may prevent it from participating in the intended client and handler lifetime rotation.

For a singleton coordinator, creating named clients through an injected IHttpClientFactory when needed is often clearer. Review the HTTP client factory troubleshooting guidance for handler scope and lifetime details.

An outgoing HTTP handler scope is not the incoming request scope. Avoid assuming that scoped data resolved inside a handler automatically represents the current request's tenant or user. Pass the relevant request information through an explicit, supported path.

Validate the Provider Consistently

Enable provider validation explicitly when you want the same checks across environments:

builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true;
});

Configure this before building the application. ValidateScopes helps detect invalid scope use, while ValidateOnBuild checks constructability of service graphs during provider creation.

These checks are valuable, but they do not prove the application is correct. Open generic registrations and dynamically executed factory logic have limitations, and ordinary object capture after resolution remains outside what registration validation can establish.

Validation also cannot prove that a singleton's mutable fields are thread safe. Nor can it prove that an asynchronous operation finishes before its caller disposes an explicitly created scope.

Avoid calling BuildServiceProvider during service registration to obtain a dependency early. That creates a second provider with separate ownership and potentially separate singleton instances.

The resulting application can contain two caches, two coordinators or resources whose disposal no longer matches the main host. Prefer registration factories or the framework's configuration mechanisms so services resolve from the intended provider.

Diagnose a Lifetime Bug Systematically

Start from the symptom and identify the actual object involved. A disposed context exception tells you which resource became unusable, but the mistake may be the caller that ended its scope too early.

Temporarily log a diagnostic instance identifier when a suspect service is created and when its work runs. Include request or job correlation identifiers, but avoid sensitive payloads.

For example, a repository can expose a generated identifier solely for diagnostic logging. If unrelated requests show the same identifier for a service expected to be scoped, inspect singleton capture and root resolution.

If the identifier is correct but work continues after scope disposal, inspect fire-and-forget calls, returned tasks and deferred enumeration.

Search the codebase for lifetime registrations and suspicious ownership patterns:

rg "AddSingleton|AddScoped|AddTransient" src
rg "BuildServiceProvider|CreateScope|CreateAsyncScope" src
rg "Task.Run|GetRequiredService|IServiceProvider" src

These searches identify review candidates; none of these APIs is inherently wrong. Follow each result to understand who owns the resolved service and how long the operation uses it.

Document the graph in plain terms: “singleton worker retains scoped handler, which retains request context”. A clear ownership sentence often makes the correct change obvious.

Work Through a Realistic Failure

Suppose a scheduled invoice worker throws “a second operation was started on this context” only when several invoices arrive together.

The registrations show the worker is long lived and the invoice handler is scoped. Inspection reveals that the worker creates one scope at startup, stores the handler, then uses it for every job.

The scope technically remains alive, so disposed-object errors do not occur. The failure is concurrent use of one context and state retained across independent jobs.

Move scope creation inside the per-job operation. Resolve one handler for each job, await completion and dispose that job's scope. Keep the concurrency limit explicit so independent contexts do not overwhelm the database.

Next, run overlapping jobs including one that fails and one that is cancelled. Verify that one job's failure does not dispose another job's context and that each job releases its own resources.

Finally, inspect shutdown. The worker should stop accepting work according to its queue contract, propagate cancellation where appropriate and let scopes dispose as operations end. A happy-path fix is incomplete if shutdown still abandons live work.

Verify the Design Under Overlap and Cancellation

A simple resolution test is useful for detecting missing registrations, but it cannot exercise lifetime races. The relevant scenarios involve overlapping ownership intervals.

Run two independent request scopes and confirm they receive different scoped service instances. Resolve the service twice within one scope and confirm the intended shared instance behaviour.

Exercise concurrent calls to any singleton that stores mutable state. Use different tenant or customer identifiers so accidental state sharing becomes visible in the result.

For background work, cover success, exception, cancellation and host shutdown. Verify that resources are released once and that no service is used after its scope ends.

Do not add retries to conceal deterministic ownership failures. A disposed context will remain disposed, and rerunning a job with the same captured handler can repeat the mistake.

Keep diagnostics proportionate. Temporary instance identifiers are useful during investigation; permanent high-volume lifecycle logging can obscure the application behaviour you actually need to observe.

Improve the Design Beyond the Immediate Fix

After correcting the faulty registration, look for the design pressure that produced it. A singleton may have accumulated database access because it combined caching, querying and job coordination in one class.

Separating those responsibilities can remove the temptation to retain scoped services. A scoped loader retrieves data, an immutable result represents it, and a shared cache stores that result with an explicit refresh policy.

Make factory ownership visible in naming and interfaces. A method that returns a caller-owned context or disposable session should make that contract clear to consumers.

Keep scope creation near the application boundary that defines the work: the request pipeline, a queue consumer or a scheduled job runner. Scattering hidden scopes inside low-level helpers makes transactions, disposal and shared state harder to understand.

Review new singleton registrations carefully. Their convenience is real, but every retained dependency and mutable field becomes part of an application-wide sharing decision.

Check Middleware and Request Context Boundaries

Conventional middleware is commonly constructed once for the application pipeline. Injecting a scoped service into its constructor can therefore create the same lifetime mismatch as a singleton service.

When middleware needs a request-scoped dependency, resolve it through the request execution method's supported injection mechanism. Alternatively, factory-based middleware can provide an explicitly scoped activation model. Microsoft's middleware dependency injection guidance explains these activation choices.

The request boundary matters beyond middleware. An accessor that can locate the current HTTP context does not make it safe to store that context in a singleton field. The context belongs to a particular request and should not be retained for later work.

Read the required values while the request is active and pass a suitable immutable payload to another operation. A tenant identifier or correlation identifier can be copied deliberately; the complete request object brings many unrelated lifetime assumptions with it.

Consider an audit middleware that records the current user after the next component completes. Awaiting the next component keeps the logic within the request's execution path. Starting an untracked task that reads the context later creates a different ownership problem.

For a queued audit operation, capture the values that the audit record needs before enqueueing. The consumer can create its own scope and write the record without relying on a request that has already ended.

This also makes tests clearer. Supply two overlapping requests with distinct users and verify that each audit record carries the correct identifier. The test checks the actual privacy and correctness risk rather than merely asserting that a middleware object was constructed.

Summary

Dependency injection lifetimes define creation and ownership rules. Correctness also depends on which objects retain dependencies, whether calls overlap and when asynchronous work finishes.

Keep request-oriented services scoped, create explicit scopes for independent background jobs and await work before disposing its owner. Let the responsible owner dispose resources, and avoid storing request data in shared services.

Use provider validation to catch invalid graphs, then verify concurrency, cancellation and shutdown behaviour. A singleton is appropriate when its complete dependency graph and state are safe to share for the lifetime of the application.