Dependency injection (DI) in ASP.NET Core means you register your application’s services with the built-in service collection, and the framework then supplies those services to any class that declares them, usually through its constructor. You decide each service’s lifetime at registration time: transient, scoped, or singleton. Most of the bugs people hit come from choosing the wrong lifetime or from using a scoped service where the framework has no scope to give it. This guide walks through the registration forms, the three lifetimes, the scope boundaries and the captive-dependency error, then constructor injection and middleware.
The examples follow the current Microsoft Learn article, Dependency injection in ASP.NET Core, which targets .NET 10. Features such as keyed services are version-dependent, so if your project targets an earlier .NET version, check the matching versioned documentation before relying on a specific API.
How the container fits into an ASP.NET Core app
ASP.NET Core ships with a built-in DI container. At startup you add service descriptions to builder.Services; when the app is built, the container can construct any registered service and its dependencies on demand. In current templates these registrations live in Program.cs, before builder.Build() is called.
Your classes do not create their collaborators with new. They declare what they need, and the container passes instances in. This keeps the composition of the application in one place and makes it easy to swap an implementation for a test double.
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →#1 Best Overall
Registering services in Program.cs
A registration tells the container three things: the type a consumer will ask for, the type that will be created, and the lifetime. The service registration guidance describes the overloads in detail. In practice you use one of four forms:
| Registration form | Example | What the container does |
|---|---|---|
| Abstraction bound to implementation | builder.Services.AddScoped<IOrderService, OrderService>(); |
Returns an OrderService whenever IOrderService is requested. |
| Concrete type | builder.Services.AddTransient<PasswordHasher>(); |
Registers the class under its own type, with no interface. |
| Factory | builder.Services.AddSingleton<IReportCache>(sp => new ReportCache(sp.GetRequiredService<IConfiguration>())); |
Calls your delegate with the provider, so you can resolve other services yourself. |
| Existing instance | builder.Services.AddSingleton<IAppInfo>(new AppInfo("v1")); |
Returns the object you created; the container does not construct it. |
Register the lifetime that matches what the service holds (covered below). A complete Program.cs fragment looks like this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddTransient<PasswordHasher>();
var app = builder.Build();
Entity Framework Core’s AddDbContext method registers DbContext as scoped by default, so a database context normally follows the request lifetime without extra configuration.
Keyed services
Keyed services let you register several implementations of one interface and select one by key. The current Microsoft article documents AddKeyedSingleton, AddKeyedScoped and AddKeyedTransient. Keyed services were introduced in .NET 8, so they are available on .NET 8 and later targets.
Recommended Free Tools
Rank #2
builder.Services.AddKeyedSingleton<IStorage, LocalStorage>("local");
builder.Services.AddKeyedSingleton<IStorage, CloudStorage>("cloud");
public class Uploader
{
public Uploader([FromKeyedServices("cloud")] IStorage storage)
{
_storage = storage;
}
private readonly IStorage _storage;
}
The three service lifetimes
The lifetime controls how long an instance is reused. Microsoft’s service lifetimes article defines the three lifetimes, and the usage guidance includes a worked example of the behavior.
| Lifetime | Resolution behavior | Typical web use and caution |
|---|---|---|
| Transient | A new instance each time it is requested. | Suits lightweight services that should not share state. If a transient is disposable and is resolved from the root provider, the container keeps a reference to it until the provider is disposed, so avoid resolving disposable transients at the root. |
| Scoped | One instance per scope; every request inside that scope receives the same instance. | In MVC and Razor Pages, ASP.NET Core creates one scope per HTTP request. Scoped services are the normal choice for per-request state such as a DbContext. |
| Singleton | One instance for the lifetime of the service provider. | Requests run concurrently, so a singleton must be thread-safe. It keeps its object graph and state for the whole app lifetime. |
Transient
Use transient for stateless or cheap services where sharing would be wrong, such as a small formatter that holds per-call values. Each consumer that asks for the service gets its own copy, so a transient that accumulates state will not leak that state into other callers.
Scoped
Scoped is the default choice for anything tied to a unit of work. In a web app that unit is the HTTP request, so everything resolved while handling one request shares the same instance, and the instance is disposed when the request ends. Two classes in the same request that both depend on IOrderService receive the same object.
Singleton
Singleton is appropriate for configuration caches, clients that manage their own connection pools, and other objects designed to be shared. Because many requests can use one singleton at once, its code must be safe under concurrent access. A singleton that keeps mutable request data in a field will eventually expose one user’s data to another.
Scope boundaries and the captive dependency
A scope is the boundary inside which scoped services are reused and disposed. ASP.NET Core provides an implicit scope for each request. Outside a request, such as in a background worker, your code must create its own scope. Microsoft states the rule directly in the service lifetimes article:
“A scoped service should always be used from within a scope–either an implicit scope (such as ASP.NET Core’s per-request scope) or an explicit scope created with IServiceScopeFactory.CreateScope().”
What the captive dependency error means
A captive dependency occurs when a longer-lived service holds a shorter-lived one. If a singleton receives a scoped service through its constructor, the singleton keeps that one scoped instance for the rest of the app’s life. The scoped object, and anything it references such as a DbContext with an open connection, outlives the request it was meant for. Development-time scope validation reports this as an error so you can fix the registration before it causes data leaks.
builder.Services.AddSingleton<ReportWorker>();
builder.Services.AddScoped<AppDbContext>(); // not allowed to flow into ReportWorker
public class ReportWorker
{
public ReportWorker(AppDbContext db) // captive dependency
{
_db = db;
}
private readonly AppDbContext _db;
}
Fixing it with IServiceScopeFactory
Inject IServiceScopeFactory instead, which is a singleton itself, and create a scope for each unit of work. Dispose the scope when the work finishes.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problems- Change the constructor parameter from the scoped type to
IServiceScopeFactory. - Call
CreateScope()at the start of each unit of work. - Resolve the scoped service from
scope.ServiceProvider. - Let the
usingblock dispose the scope when the work completes.
public class ReportWorker
{
private readonly IServiceScopeFactory _scopeFactory;
public ReportWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public void RunOnce()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// use db for this unit of work only
}
}
Constructor injection
Constructor injection is the preferred way to supply application dependencies. A class lists what it needs in its constructor, and the container fills those parameters. Microsoft’s DI article recommends this approach over resolving services on demand, because the constructor shows every collaborator a class uses and makes the class straightforward to test with fakes.
The alternative is service location: calling HttpContext.RequestServices.GetService<T>() from inside a method. It works, but it hides the dependency from anyone reading the class signature and makes unit tests harder to set up. Reserve it for framework code and narrow cases where constructor injection is genuinely impossible.
When the constructor gets long
A constructor with many parameters is a design signal. If a class asks for eight services, it probably does more than one job. Split the responsibilities into smaller services rather than replacing constructor parameters with service-location calls, which keeps the dependency list honest.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Injecting services into middleware
Conventional middleware is created once and reused for every request. Its constructor therefore runs at application startup, which means scoped services cannot be injected there. The framework passes scoped dependencies to the Invoke or InvokeAsync method instead, which runs once per request.
Best Value
- Applying all key ASP.NET Core components, including MVC for HTML generation, .NET Core, EF Core, ASP.NET Identity, dependency injection, and more
- Integrating ASP.NET Core with leading client-side frameworks, including Bootstrap
- ASP.NET Core code for implementing business logic and data transformations
- Handling configuration, routing, controllers, views, and common tasks (including posting forms and presenting data)
- Performing complementary tasks: error handling, logging, application design, authentication, localization, and more
Conventional middleware with per-request services
Keep the constructor limited to RequestDelegate, and declare scoped parameters on InvokeAsync:
public class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, AppDbContext db)
{
// db is resolved from the current request's scope
await _next(context);
}
}
app.UseMiddleware<TenantMiddleware>();
Factory-based middleware
If you want the middleware object itself to be activated by the container for each request, implement IMiddleware, register the class in the container, and add it to the pipeline by type. Its InvokeAsync method receives HttpContext and the RequestDelegate, and its own constructor can take scoped dependencies because the container builds it per request.
public class AuditMiddleware : IMiddleware
{
private readonly AppDbContext _db;
public AuditMiddleware(AppDbContext db)
{
_db = db;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// record the request using _db
await next(context);
}
}
builder.Services.AddScoped<AuditMiddleware>();
app.UseMiddleware<AuditMiddleware>();
Singletons, hosted services and other non-request work
Background services are a common place for captive-dependency mistakes. A BackgroundService is registered as a singleton, so its constructor cannot accept a scoped service. Inject IServiceScopeFactory and open a scope for each cycle of work, as shown earlier. Do not inject a scoped service into the hosted service’s constructor, and do not keep a scoped instance in a field across loop iterations.
Blazor and the scope boundary
The per-request rule does not carry over unchanged to Blazor. Microsoft describes scoped lifetime in Blazor in terms of the circuit in the relevant hosting models, so in Blazor Server a scoped service typically lives as long as the user’s connection. Check the hosting documentation for the Blazor model you use before deciding whether a scoped service behaves like a per-request object.
Validation, disposal and the built-in container
Scope validation during development
The .NET DI guidelines describe scope validation for two situations: resolving a scoped service from the root provider, and injecting a scoped service into a singleton. The default host builder enables this validation in the Development environment, so the mistakes above appear when you run the app locally. It is not enabled in production by default, so rely on development runs and tests to catch them.
Disposal
The container disposes the services it creates, according to each service’s lifetime and the scope that created it. Do not dispose services that were injected into you; the container owns them. The DI guidelines cover disposal and thread-safety expectations in more detail.
When to replace the built-in container
Replace the built-in container only when a feature you need is missing. The Microsoft guidance lists property injection, child containers, custom lifetime management and convention-based registration as examples. For most ASP.NET Core applications the built-in container is enough.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

