CodeOath
← All posts
.NET Core / Web API70 min total · 19 parts

Building REST APIs with ASP.NET Core: Routing, Middleware, and Dependency Injection

Contents — Part 9 of 19: Dependency Injection and Service Lifetimes
Part 9 of 19 · ~2 min

Dependency Injection and Service Lifetimes

OrdersController's constructor asks for an IOrderService, and one just appears — that's the built-in DI container, configured in Program.cs:

builder.Services.AddScoped<IOrderService, OrderService>();

Three lifetimes decide how long an instance is reused:

LifetimeNew instance createdTypical use
TransientEvery time it's requestedLightweight, stateless services
ScopedOnce per HTTP requestAnything using a DbContext — one unit of work per request
SingletonOnce for the whole app's lifetimeShared, thread-safe state (e.g. an in-memory cache, configuration)

Getting this wrong in one specific way causes a real, hard-to-spot bug: injecting a Scoped service into a Singleton (a "captive dependency"). The Singleton is only constructed once, so whatever Scoped instance is injected into it at that moment gets captured and reused forever — silently breaking the per-request isolation the Scoped lifetime was supposed to guarantee, and (for a DbContext specifically) leading to a shared, long-lived database context used concurrently across unrelated requests, which is not thread-safe and produces confusing, intermittent errors.

// Wrong — CacheService is a singleton, so the OrderRepository it captures at construction
// time is frozen forever, defeating its per-request Scoped lifetime.
builder.Services.AddSingleton<CacheService>();
builder.Services.AddScoped<OrderRepository>();

class CacheService
{
    public CacheService(OrderRepository repository) { /* captured once, reused forever */ }
}

The framework can catch this automatically in Development if ValidateScopes is enabled (it is, by default, in the default templates), throwing at startup instead of producing a silent bug in production — one of the more valuable free safety nets in the framework, worth confirming is actually turned on rather than accidentally disabled.

The fix, when a singleton genuinely needs scoped data, is to inject IServiceScopeFactory (or IServiceProvider) instead of the scoped service directly, and create a new scope explicitly whenever the singleton needs one:

class BackgroundReportService
{
    private readonly IServiceScopeFactory _scopeFactory;
    public BackgroundReportService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public async Task RunReportAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var repository = scope.ServiceProvider.GetRequiredService<OrderRepository>(); // fresh, correctly-scoped instance
        // ...
    }
}