Dependency Injection in ASP.NET Core: Lifetimes and Best Practices

Master dependency injection in ASP.NET Core: transient, scoped, and singleton lifetimes, captive dependencies, the options pattern, and keyed services.

Executive Summary: Dependency injection in ASP.NET Core is one container with one job: it constructs your classes and supplies their dependencies through constructors, so classes never build their collaborators. The whole feature’s risk concentrates in one decision per registration: the lifetime.

The error that brings everyone to this page usually looks like this: Unable to resolve service for type 'TaskApi.Services.TaskStore' while attempting to activate 'TaskApi.Controllers.TasksController'. You forgot one registration line, and the container, refuses to invent a dependency. The second, meaner error waits until the singleton you wrote captures a service meant for a single request, and two users start seeing each other’s data.

Dependency injection in ASP.NET Core is the built-in inversion-of-control container. You register services with a lifetime, classes declare their dependencies as constructor parameters. The framework constructs the graph on demand. The official dependency injection guide documents the whole surface. This page focuses on the two decisions that produce most bugs: which lifetime, and what may depend on what.

The examples assume .NET SDK 10.0 with ASP.NET Core 10 and continue the TaskApi from the Web API guide. So every registration shown has a real home.

Here is what I learned the hard way: the container is not magic and should not be defended like it. It is a dictionary of factories plus a lifetime rule, and every mystery in it resolves to “who owns this object, and for how long?”

What dependency injection in ASP.NET Core actually is

Three pieces make up the machinery. IServiceCollection is where you register mappings during startup. IServiceProvider is the built container, the thing that knows every mapping and how to construct each service with its own dependencies. Constructor injection is the delivery mechanism: your class lists what it needs, and the container supplies it:

public sealed class TaskService(ITaskRepository repository, ILogger<TaskService> logger)
{
    public TaskItem? Find(Guid id)
    {
        logger.LogInformation("Finding task {Id}", id);
        return repository.Find(id);
    }
}

// registration, once, at startup
builder.Services.AddScoped<ITaskRepository, SqlTaskRepository>();
builder.Services.AddScoped<TaskService>();

Registration and consumption are all you write. No factories, no new inside controllers, no hidden globals. The underlying Microsoft.Extensions.DependencyInjection library is the same container used by every .NET workload, web apps included.

The three lifetimes

Every registration picks one lifetime, and the choice decides when the object is built, who shares it, and when it is disposed:

Lifetime Created Shared with Disposed Typical use
Transient Every resolution Nobody When its owning scope ends Lightweight, stateless services
Scoped Once per request (scope) Everything in that request At the end of the request DbContext, per-request state, unit-of-work
Singleton Once per process Every request, every user At shutdown Caches, configuration, stateless helpers

The same three calls register them, and the class never knows which lifetime it received:

builder.Services.AddTransient<EmailFormatter>();      // new every time
builder.Services.AddScoped<ITaskRepository, SqlTaskRepository>();  // one per request
builder.Services.AddSingleton<TaskCache>();          // one for the whole app
REQUEST 1:  [transient][transient][scoped ....][singleton ...........]
REQUEST 2:  [transient][transient][scoped ....][same singleton .......]
REQUEST 3:  [transient][transient][scoped ....][same singleton .......]
                 new each      new each    one per   one instance, all
                 resolution    resolution  request   requests share it

The scope is the important new concept for people arriving from other containers. ASP.NET Core creates one IServiceScope per HTTP request, and everything scoped resolves inside that request’s bubble. As a result, a request gets one DbContext and one repository throughout. The same mechanism powers background work, which creates its own scopes explicitly.

Captive dependencies: the bug the compiler will not catch

A captive dependency is a service with a longer lifetime that holds a service with a shorter one. The classic case: a singleton that takes a scoped DbContext. The container builds the singleton once, captures whichever DbContext it was handed at that moment, and serves every request with the same instance:

WRONG:

public sealed class ReportCache  // singleton, once for the whole app
{
    private readonly AppDbContext _db;  // scoped, captured forever

    public ReportCache(AppDbContext db) => _db = db;
}

Now every request shares one DbContext: cached entities across users, tracked rows from dead requests, and intermittent failures when two threads use the same context. The good news is that the default development environment catches the shape at startup: scope validation is on in development. You get InvalidOperationException: Cannot consume scoped service 'AppDbContext' from singleton 'ReportCache' before a single request runs. The same exception fires for any service captured by a longer-lived consumer, so treat it as a build error, not a warning to suppress.

RIGHT: resolve the scoped service on demand, through IServiceScopeFactory, each time you need it:

public sealed class ReportCache(IServiceScopeFactory scopeFactory)
{
    public async Task<Report?> GetReportAsync(int id)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        return await db.Reports.FindAsync(id);
    }
}

Each call creates a fresh scope, gets a fresh DbContext, and disposes both when the method ends. This is also the standard pattern inside single-instance background services, which never live inside a request scope in the first place.

The valid combinations table

Whether a dependency graph is legal is a two-minute lookup, not a judgment call:

Consumer has Can take Cannot take
Transient Anything (nothing is off-limits)
Scoped Transient, Scoped, Singleton (nothing is off-limits)
Singleton Singleton (a transient captured here effectively becomes a singleton too) Scoped (captive, startup exception)

Read the rows as the consumer’s lifetime and remember the rule behind them. A service may only hold things that live as long as itself, or shorter things it disposes immediately. A DbContext registered scoped, the default for EF Core, exists precisely to serve one request. The EF Core guide covers what that means for data access.

The options pattern: configuration without ceremony

Typed configuration is itself a container service. Define a settings class, bind it from appsettings.json and environment variables, then inject the wrapper that matches your consumer’s lifetime:

public sealed class TaskApiSettings
{
    public const string SectionName = "TaskApi";

    public int MaxTasksPerProject { get; set; } = 100;
    public required string DefaultOwner { get; set; }
}

builder.Services
    .AddOptions<TaskApiSettings>()
    .BindConfiguration(TaskApiSettings.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();
public sealed class TaskService(IOptionsSnapshot<TaskApiSettings> settings)
{
    public bool IsOverLimit(int taskCount) =>
        taskCount > settings.Value.MaxTasksPerProject;
}

Three wrappers cover every case: IOptions<T> is a singleton holding the value read at startup, IOptionsSnapshot<T> is scoped and recomputed per request (safe for per-request reads). IOptionsMonitor<T> is a singleton with change notifications for services that must react to reloads. The full behavior of each is in the options pattern documentation. The common mistake is injecting IOptions<T> into scoped code and expecting configuration changes to appear without a restart.

Keyed services: two implementations, one interface

Since .NET 8, a registration can carry a key, letting one interface resolve to different implementations per consumer. Two notification senders is the canonical case:

builder.Services.AddKeyedScoped<INotificationSender, EmailSender>("email");
builder.Services.AddKeyedScoped<INotificationSender, SmsSender>("sms");

public sealed class NotificationRouter(
    [FromKeyedServices("email")] INotificationSender email,
    [FromKeyedServices("sms")] INotificationSender sms)

Keys beat inventing wrapper interfaces (“IEmailNotificationSender”) when the implementations are genuinely interchangeable. When they are not, and callers genuinely need different interfaces, prefer distinct interfaces. Keyed services are for selection among equals, not for hiding design differences.

The service locator anti-pattern

When a class injects IServiceProvider and resolves its dependencies by hand, the container stops being dependency injection and becomes a service locator: a hidden dependency source invisible to the class’s constructor and to anyone reading it.

WRONG: dependencies invisible until the method runs.

public sealed class TaskService(IServiceProvider provider)
{
    public TaskItem? Find(Guid id)
    {
        var repo = provider.GetRequiredService<ITaskRepository>();
        return repo.Find(id);
    }
}

RIGHT: dependencies declared, checkable, and swappable in tests.

public sealed class TaskService(ITaskRepository repository)
{
    public TaskItem? Find(Guid id) => repository.Find(id);
}

Two legitimate homes exist for GetRequiredService: infrastructure you cannot avoid (resolving inside InvokeAsync middleware parameters aside, and scope-created background work as shown above), and framework extension points that construct your type. In application code, constructor parameters keep the dependency graph honest. Swapping implementations for tests stays a one-line change, the exact property that integration testing with WebApplicationFactory relies on when it replaces real services with fakes.

How real systems do this

  • Registration grouped by area, not one long Program.cs. Teams write extension methods like AddInfrastructure(this IServiceCollection) and AddApplication(this IServiceCollection). As a result, a new project adds three lines instead of eighty. The clean architecture guide shows how those groups map to layers.
  • Registrations verified by a startup test. One integration test resolves the application’s root services, so a missing registration fails in CI with a clear message instead of the first request in production.
  • Validation stays on in every environment. UseDefaultServiceProvider options turn scope validation on in production too, for teams that prefer the strictest behavior everywhere rather than the dev-only default.

A bug I have seen in production came from a “caching service” registered as a singleton that accepted a per-request user-context service in its constructor. It worked in every local test because one developer, one request at a time, and leaked in production when two users hit the API in the same second. User A’s tenant showed up in user B’s report header. The data leak was intermittent for days before anyone connected it to lifetime. The takeaway: every constructor parameter of a singleton is global state, so review it like global state.

Decision framework: choosing a lifetime

  1. Does the service hold request-specific state? Scoped. DbContext, current-user context, and unit-of-work objects belong to exactly one request.
  2. Does it hold shared state or expensive resources? Singleton, built thread-safe: caches, connection factories, configuration.
  3. Is it small, stateless, and cheap? Transient is the sensible default; construction cost is the only cost.
  4. Is configuration its dependency? Use the options wrappers: IOptions for singletons, IOptionsSnapshot for scoped consumers, IOptionsMonitor for change-reactive singletons.
  5. Do several implementations of one interface exist by design? Keyed services when they are interchangeable; separate interfaces when they are not.

When NOT to add registrations

  • When a static pure function is enough. A stateless calculation, mapping, or validator does not need a service, an interface, or a container. Make it static and move on.
  • When the interface has one implementation “for later”. Registering IFoo implemented by exactly one Foo adds indirection with no seam. Extract the interface when a second implementation or a test double actually arrives.
  • When the class is framework-constructed per request for other reasons. Controllers and minimal API handlers are constructed per call anyway. Treat them as the boundary, not as services to register.

Common mistakes

  • Capturing a scoped service in a singleton. The development-time exception is the warning. In production it surfaces as shared state across users, the worst kind of bug.
  • Injecting IServiceProvider everywhere. Service locator hides dependencies from the constructor and from readers; declare dependencies as parameters.
  • Storing scoped services in static fields. The field outlives the scope. The disposed DbContext then fails with “ObjectDisposedException” long after the request ended.
  • Heavy work in constructors. Anything resolved at startup (singletons especially) runs before the first request; slow constructors delay readiness and hide inside “why is deployment slow”.
  • Registering and forgetting disposal. The container disposes IDisposable services it creates. Services created by hand inside factories must be handed to the container or disposed by the code that created them.
  • Assuming IOptions reflects configuration changes. IOptions<T> reads once at startup; per-request freshness needs IOptionsSnapshot<T>, and change notification needs IOptionsMonitor<T>.

The concepts carry over from other ecosystems. My guides on dependency injection concepts in Java and dependency injection in Spring Boot cover the same ideas with a different container.

Key takeaways

  • The container constructs classes and supplies constructor parameters; registration plus lifetime choice is all you write.
  • Transient resolves per use, scoped resolves per request, singleton resolves once per process. The table of valid combinations is the whole rulebook.
  • Captive dependencies (a longer lifetime holding a shorter one) fail at startup in development and corrupt state in production. Resolve scoped services on demand via IServiceScopeFactory instead.
  • Configuration arrives through the options pattern: IOptions, IOptionsSnapshot, IOptionsMonitor, with validation at startup.
  • Keyed services select among interchangeable implementations of one interface; distinct interfaces when the implementations are not equals.
  • Constructor injection beats service location everywhere except infrastructure extension points; a class’s dependencies should be readable from its constructor alone.

FAQ

What are transient, scoped, and singleton in ASP.NET Core?

They are the three service lifetimes. Transient creates a new instance every time the service is resolved, scoped creates one instance per HTTP request and shares it within that request. Singleton creates one instance for the whole application lifetime. The consumer never changes; only who shares the instance and when it is disposed changes.

What is a captive dependency in C#?

A service with a longer lifetime holding a service with a shorter one, such as a singleton that captures a scoped DbContext in its constructor. The captured instance outlives its intended scope, so every request shares state that should have been per-request. Development mode detects the shape at startup with an InvalidOperationException. The fix is resolving the scoped service on demand through IServiceScopeFactory.

How do I use a scoped service inside a singleton?

Inject IServiceScopeFactory, create a scope when the work happens with scopeFactory.CreateScope(), resolve the scoped service from scope.ServiceProvider, and dispose the scope when done. Each use gets a fresh instance with the correct lifetime, which is also the standard pattern in single-instance background services.

What is the options pattern in ASP.NET Core?

A way to bind configuration to strongly typed classes and inject them as services. You register a settings class with AddOptions and BindConfiguration, then inject IOptions for singleton consumers, IOptionsSnapshot for per-request values, or IOptionsMonitor for change notifications. Validators can run at startup so bad configuration fails deployment instead of the first request.

What are keyed services in .NET?

Registrations that carry a string or type key, added in .NET 8, so one interface can have several implementations and consumers select one with the FromKeyedServices attribute. Use them when implementations are interchangeable and differ only by choice, such as an email versus an SMS notification sender behind one interface.

Register explicitly, inject through constructors

The container rewards explicitness: every dependency visible in a constructor, every lifetime chosen on purpose, every scoped service resolved through a scope when a singleton needs it. The rest of the framework leans on this. Middleware gets scoped services as method parameters, background workers build their own scopes. Integration tests swap implementations by editing the same registrations you review in the pull request. Next in this area, authentication and authorization builds on the container’s service handling, and every guide in the series lives on the .NET series index. The rule of thumb: a class’s dependencies should be readable from its constructor alone, and its lifetime should be defensible in one sentence.

Last updated on 30 October 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *