ASP.NET Core / Basic: HTTP and Application Composition / Lesson 6
Dependency Injection and Lifetimes
Choose construction, sharing, scope, and disposal from the operation’s ownership rather than from registration convenience.
On this page
Lesson goals
- Distinguish singleton, scoped, and transient contracts.
- Avoid captive dependencies and service-location shortcuts.
- Verify scoped sharing and disposal in memory.
Lifetime defines sharing and ownership
Singletons are shared for the container lifetime, scoped services are shared within a scope, and transient services are created for resolutions under container rules. A normal HTTP request has a scope; a background worker does not automatically acquire the same per-request lifetime. These choices affect state, thread safety, memory retention, and disposal.
Use constructor/handler dependencies to expose what an operation requires. Do not resolve arbitrary services from a global provider whenever convenient. A singleton capturing a scoped dependency can extend its use beyond the intended scope and can be rejected by scope validation. Validation options are useful evidence but not a proof of every lifetime rule.
A scope shares one scoped instance
The first program creates two explicit scopes and compares resolved object identity. Two resolutions inside the first scope return the same object, while the second scope receives another. The test uses the real DI container in an isolated console, with no HTTP host. It checks identity sharing, not every request-lifetime integration behavior.
Do not store request-specific mutable data in a singleton merely to avoid passing parameters. A scoped service can still be used concurrently by code within the same request, so scope alone does not imply thread safety. Keep HttpContext and user-derived state within their valid request lifetime.
using System;
using Microsoft.Extensions.DependencyInjection;
var collection = new ServiceCollection();
collection.AddScoped<Marker>();
using var provider = collection.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
using var first = provider.CreateScope();
using var second = provider.CreateScope();
var a = first.ServiceProvider.GetRequiredService<Marker>();
var b = first.ServiceProvider.GetRequiredService<Marker>();
var c = second.ServiceProvider.GetRequiredService<Marker>();
Console.WriteLine($"sameScope={ReferenceEquals(a, b)} differentScopes={ReferenceEquals(a, c)}");
internal sealed class Marker { }
sameScope=True differentScopes=False
The first two resolutions share one Marker; a second scope gets a distinct Marker.
The container disposes what it owns at the proper boundary
The second program resolves a disposable scoped object and observes that scope disposal releases it once. A service should not dispose a borrowed dependency supplied by DI merely because its method finished. Conversely, a factory-created resource outside container ownership needs an explicit owner. Async disposal needs an async-capable scope/ownership path where required.
Registering an already constructed singleton instance has different disposal responsibility from container-created service instances under the documented rules. Review that distinction before assuming every object is automatically released. Do not capture disposable transients in a long-lived root resolution just to make code shorter.
using System;
using Microsoft.Extensions.DependencyInjection;
var collection = new ServiceCollection();
collection.AddScoped<OwnedResource>();
using var provider = collection.BuildServiceProvider();
using (var scope = provider.CreateScope())
{
_ = scope.ServiceProvider.GetRequiredService<OwnedResource>();
Console.WriteLine($"inside={OwnedResource.Disposals}");
}
Console.WriteLine($"after={OwnedResource.Disposals}");
internal sealed class OwnedResource : IDisposable
{
public static int Disposals { get; private set; }
public void Dispose() => Disposals++;
}
inside=0
after=1
The resource remains live inside the scope and is disposed before the final line.
Background work needs an explicit scope and completion owner
A hosted singleton worker can create a scope for a bounded unit of work and await all users before disposing it. Do not inject a request-scoped DbContext into a singleton or retain HttpContext after the request. Factories can make resource creation explicit, but they do not remove the need to dispose and handle failures.
Avoid BuildServiceProvider during application registration because it can create an independent container and duplicate singleton graphs. Prefer supported factories/options and the framework’s composed provider. Keep environment-specific registrations at the composition root while domain services depend on narrow contracts.
Test lifetime assumptions, not just successful resolution
Review service identity, mutable state, concurrent callers, resource ownership, cancellation, and disposal on failure. A service resolving successfully once can still be retained too long or used after its scope ends. Build-time/scope validation helps detect some graphs but cannot infer the application’s complete business lifecycle.
Test same-scope sharing, cross-scope independence, disposal order where relevant, early failure, and background completion. Keep test-created providers isolated and disposed. The course examples create only local containers and simple objects; they do not resolve configured application services or open a database.
Common errors and fixes
A singleton captures a scoped dependency.
- Why it happens
- A shorter lifetime is retained by a longer owner.
- How to fix it
- Use a justified scope/factory boundary for each operation.
- Prevent it next time
- Enable relevant scope validation and test ownership.
A request service is assumed thread-safe because it is scoped.
- Why it happens
- Sharing scope was confused with concurrency control.
- How to fix it
- Review all simultaneous users and state access.
- Prevent it next time
- Keep mutable request state bounded and explicit.
An extra provider is built during registration.
- Why it happens
- Composition creates duplicate service ownership graphs.
- How to fix it
- Use the intended application provider and supported registration factories.
- Prevent it next time
- Review provider creation as a lifecycle decision.
Check your understanding
1. A background job needs one DbContext per batch. What lifetime pattern should be reviewed instead of injecting it into a singleton worker?
Show worked solution
Create a scope or use a suitable context factory for each bounded batch, resolve/create the context there, await the batch’s work, then dispose the owned resource/scope. Do not share one context across parallel operations. Keep cancellation and failure cleanup explicit. The correct design depends on EF/provider configuration; a successful DI example alone does not validate a database unit of work.
2. Why should a service not call Dispose on every dependency it receives?
Show worked solution
Receiving a reference does not mean owning its lifetime. DI may share that dependency within a scope or across the application, and early disposal can break other users. Follow the documented container/factory ownership boundary. A resource created explicitly by the service may need its own using/await-using, while borrowed dependencies normally remain the owner’s responsibility.
Key takeaways
- Lifetimes define sharing, concurrency exposure, and disposal ownership.
- Scopes do not automatically make state thread-safe.
- Background work must own and await the resources it uses.