.NET Core / Web API71 min total · 19 parts
Building REST APIs with ASP.NET Core: Routing, Middleware, and Dependency Injection
Part 9 of 19 · ~5 min
Dependency Injection and Service Lifetimes
Look at ReservationsController's constructor and there's an IReservationService parameter with nothing obviously feeding it — and yet it shows up fully built on every single request. That's the container Dana wired up in Program.cs doing exactly the job it exists for:
builder.Services.AddScoped<IReservationService, ReservationService>();
builder.Services.AddScoped<ReservationRepository>();
builder.Services.AddDbContext<BenchDbContext>(o => o.UseSqlServer(connectionString));
Every registration picks one of three lifetimes, and the choice decides how long that particular instance survives:
| Lifetime | When a fresh instance gets built | Bench's use |
|---|---|---|
Transient | Every single time it's asked for | Small, stateless helpers — a slot-formatting utility |
Scoped | Once per HTTP request | BenchDbContext, ReservationRepository — everything that needs one consistent view of the world for exactly one request |
Singleton | Once, for as long as the process keeps running | Shared config, and — it turns out — the source of the whole incident |
This is the chapter where the Tuesday-afternoon bug actually starts. Here's what Dana was trying to build: a lightweight in-memory availability cache that could answer "is this slot free?" fast, without a round trip to SQL Server, for the kiosk's polling loop — the three lobby stations refresh their screens every few seconds, and hitting the database for every single poll from every station felt wasteful. AvailabilityCache needed to survive for as long as the process itself did, so it went in as a Singleton. And the fastest way to answer "is it free" was to just ask the same ReservationRepository the write path already used.
Attempt one — inject it directly:
builder.Services.AddSingleton<AvailabilityCache>();
builder.Services.AddScoped<ReservationRepository>();
class AvailabilityCache
{
public AvailabilityCache(ReservationRepository repository) { /* ... */ }
}
This never even made it to a running app. dotnet run failed to start at all, with a message that says exactly what's wrong: Cannot consume scoped service 'ReservationRepository' from singleton 'AvailabilityCache'. This is ValidateScopes — on by default in Development — refusing to build a container that would let a singleton lock in a scoped instance forever. It's a genuinely good outcome: the mistake dies at dotnet run, before a single request goes out, loud and unambiguous. (Confirmed directly — that's the framework's own exception text, not a paraphrase.)
Attempt two — route around it:
class AvailabilityCache
{
private readonly IServiceProvider _root;
public AvailabilityCache(IServiceProvider root) => _root = root; // no scoped param in the constructor
public async Task<bool> IsFreeAsync(int toolId, DateTime start) =>
!(await _root.GetService<ReservationRepository>()!.HasOverlapAsync(toolId, start));
}
The reasoning felt sound: grab the repository when it's actually needed, not at construction time, so there's no scoped dependency sitting in the constructor for the validator to object to. It compiles, and dotnet run starts cleanly this time. It's the first request to IsFreeAsync that fails — the same validator, still watching, just later: Cannot resolve scoped service 'ReservationRepository' from root provider. Resolving straight off the app's root IServiceProvider — which is what a singleton's constructor always receives — is functionally the same mistake as attempt one; the container just couldn't see it until the actual call happened. Still loud, still caught in Development, just one request later than before.
Attempt three is the one that shipped, and it's the one worth sitting with, because it looks like the officially-recommended fix:
class AvailabilityCache
{
private readonly IServiceScope _scope; // held for the app's whole lifetime — this is the bug
public AvailabilityCache(IServiceScopeFactory scopeFactory) => _scope = scopeFactory.CreateScope();
public async Task<bool> IsFreeAsync(int toolId, DateTime start)
{
var repository = _scope.ServiceProvider.GetRequiredService<ReservationRepository>();
return !(await repository.HasOverlapAsync(toolId, start));
}
}
IServiceScopeFactory genuinely is the documented answer to "a singleton needs scoped data" — creating a scope on demand, resolving from it, and disposing it once you're done is exactly right. The mistake here is one word: once. _scope is created a single time, in the constructor, and never disposed — every call to IsFreeAsync for the rest of the app's uptime resolves ReservationRepository from that same original scope. As far as the DI container is concerned, nothing is wrong: a legitimate scope was created through the legitimate API, and a scoped service was correctly resolved from it. The container has no way to know the scope was supposed to be short-lived and is being kept forever instead — and confirmed directly, this version throws nothing, anywhere, in any environment. ValidateScopes has nothing to flag, because no rule was actually broken; the discipline that got skipped — create a scope, use it, throw it away — was never something the framework could enforce for you in the first place.
There's a second, entirely separate reason attempt three shipped unnoticed, and it's about how Bench actually starts. Locally, Dana always ran dotnet run, which quietly reads launchSettings.json and sets the environment to Development — exactly where ValidateScopes defaults to on. The container image that actually deploys to production runs the published DLL directly — dotnet Bench.dll — with no launchSettings.json anywhere near it, and confirmed directly, that defaults to Production, where the same check is off by default. Attempts one and two would have been caught in either environment regardless, because they trip an actual container-shape error. Attempt three trips nothing in either one — but it's worth knowing that even a version that does depend on ValidateScopes can pass every local check and still ship unguarded, purely because of which binary actually starts the process.
A singleton that legitimately needs a look at scoped data still reaches for IServiceScopeFactory — that part of attempt three was never wrong. What changes is discipline: a new scope for every single unit of work, torn down the moment that work is done, never kept around for the next call to reuse:
class AvailabilityCache
{
private readonly IServiceScopeFactory _scopeFactory;
public AvailabilityCache(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task<bool> IsFreeAsync(int toolId, DateTime start)
{
using var scope = _scopeFactory.CreateScope(); // fresh every call — disposed at the end of this method
var repository = scope.ServiceProvider.GetRequiredService<ReservationRepository>();
return !(await repository.HasOverlapAsync(toolId, start));
}
}
Same API as the version that shipped, one using away from being correct. What that one word actually costs Bench is the subject of the EF Core chapter just ahead — a frozen repository is a mess on its own, but a frozen repository sitting on top of a frozen DbContext is where the Tuesday-afternoon incident actually comes from.