Mastering Service Lifetimes in .NET Core DI: A Practical Guide
Learn how to register and verify Transient, Scoped, and Singleton services in .NET Core, avoid common pitfalls, and ensure correct lifetime behavior with concrete examples and diagnostics.
29 Jun 2026, 18:30 UTC

Desired Outcome
The goal is to set up a .NET Core application that correctly registers services with the built‑in IServiceProvider, uses the appropriate lifetime (Transient, Scoped, or Singleton), and can verify that each lifetime behaves as expected across multiple HTTP requests. The article also covers how to detect and recover from common DI mistakes such as captive dependencies, memory leaks, and circular references.
Prerequisites
- Visual Studio 2022 or VS Code with the .NET SDK (6.0 or later).
- Basic knowledge of C# classes, constructors, and interfaces.
- A simple ASP.NET Core web API project (or a console app that mimics request‑scoped behaviour).
Procedure
1. Define Service Interfaces and Implementations
public interface ICounter
{
Guid Id { get; }
void Increment();
}
public class Counter : ICounter
{
public Guid Id { get; } = Guid.NewGuid();
private int _value;
public void Increment() => _value++;
}
The Counter class exposes a unique Guid so we can track instance identity during diagnostics.
2. Register Services with Desired Lifetimes
Open Program.cs (or Startup.cs if using an older template) and add the following inside builder.Services:
builder.Services.AddTransient<ICounter, Counter>(); // Transient
builder.Services.AddScoped<ICounter, Counter>(); // Scoped
builder.Services.AddSingleton<ICounter, Counter>(); // Singleton
Only one registration should be active at a time for a given interface. In a real project you would choose the lifetime that matches the intended usage.
3. Inject the Service into a Controller
[ApiController]
[Route("api/[controller]")]
public class CounterController : ControllerBase
{
private readonly ICounter _counter;
public CounterController(ICounter counter)
{
_counter = counter;
}
[HttpGet]
public ActionResult Get() => Ok(_counter.Id);
}
Constructor injection is the standard pattern; the DI container will provide the appropriate instance based on the registered lifetime.
4. Test Lifetime Behavior
Run the application and make several HTTP GET requests to /api/counter. Observe the Guid returned:
- Transient: a new
Guideach request. - Scoped: same
Guidwithin a single request, but different across requests. - Singleton: the same
Guidfor every request.
To automate this check, add a simple middleware that logs the instance hash code:
app.Use(async (context, next) =>
{
var counter = context.RequestServices.GetRequiredService<ICounter>();
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Counter instance: {Hash}", counter.GetHashCode());
await next();
});
5. Verify Registration Errors at Startup
During builder.Build(), the container validates registrations. If you register a scoped service into a singleton, the container will throw an InvalidOperationException at startup. Example error:
Error: Cannot consume service of type 'ScopedService' from singleton 'SingletonService'.
To catch this early, enable the built‑in diagnostics:
builder.Services.AddSingleton<SingletonService>();
builder.Services.AddScoped<ScopedService>();
Running the app will produce the error above, preventing a captive dependency from silently leaking.
6. Handle Circular Dependencies
If two services depend on each other, the container will throw an exception during resolution. Example:
public class A { public A(B b) {} }
public class B { public B(A a) {} }
To resolve this, refactor to use a factory or an interface that breaks the cycle, or inject IServiceProvider and resolve lazily.
7. Recover from Runtime Resolution Failures
When manually resolving services (e.g., var svc = provider.GetService<ISvc>()), always check for null and provide a fallback:
var svc = provider.GetService<ISvc>() ?? new DefaultSvc();
For scoped services outside of a request, use IServiceScopeFactory:
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
var scopedSvc = scope.ServiceProvider.GetRequiredService<IScopedSvc>();
}
Expected Checks
- Verify that the
Guidor hash code matches the expected lifetime pattern. - Check startup logs for DI validation errors.
- Use a debugger to inspect
provider.GetServices<ICounter>()and confirm the correct instance count. - Run
dotnet teston a unit test that resolves the service multiple times and asserts instance identity.
Limitations and Practical Tips
- The built‑in container does not support property injection or named registrations; use constructor injection only.
- Singletons that hold onto large object graphs can cause memory pressure; monitor
GC.GetTotalMemory()during load tests. - Scoped services are only meaningful within a request context; outside of that, use
IServiceScopeFactoryto create an explicit scope. - When upgrading to .NET 7, the DI container gains support for
AddSingleton<TService, TImplementation>(IServiceProvider)for factory overloads; consider using it for complex initialization.
Conclusion
Correctly managing service lifetimes in .NET Core DI is critical for application correctness and performance. By registering services with the appropriate lifetime, validating at startup, and verifying runtime behavior with simple hash or Guid checks, you can avoid common pitfalls such as captive dependencies, memory leaks, and circular references. Use the recovery patterns shown to gracefully handle resolution failures and keep your application robust.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.