.NET Core / Web API71 min total · 19 parts
Building REST APIs with ASP.NET Core: Routing, Middleware, and Dependency Injection
Part 11 of 19 · ~3 min
Entity Framework Core: DbContext Lifetime and Async Queries
BenchDbContext lives at Scoped — a fresh instance, carrying its own change-tracking, for each individual HTTP request — and holding that fact in mind is exactly what makes the previous chapter's AvailabilityCache bug worse than it first looked:
builder.Services.AddDbContext<BenchDbContext>(o => o.UseSqlServer(connectionString));
public class ReservationRepository(BenchDbContext db)
{
public Task<bool> HasOverlapAsync(int toolId, DateTime start) =>
db.Reservations.AnyAsync(r => r.ToolId == toolId && r.Status == ReservationStatus.Confirmed
&& start < r.EndUtc && r.StartUtc < start.AddMinutes(15));
public async Task<Reservation> CreateAsync(Reservation reservation)
{
db.Reservations.Add(reservation);
await db.SaveChangesAsync(); // one round trip, every tracked change in this DbContext applied at once
return reservation;
}
}
Microsoft's own documentation is blunt about this: a DbContext instance was never built to have two operations running against it at once, full stop. Which is exactly why anything that pins one down outside its normal Scoped lifetime — a straight Singleton registration, or a ReservationRepository accidentally frozen inside one, which is precisely what attempt three in the DI chapter did — isn't a minor style complaint. It's a correctness bug sitting there waiting for enough concurrent traffic to actually trip it.
Here's the mechanism, confirmed directly against a shared DbContext instance under genuine concurrent access, and it's worth being precise about what actually happens, because it isn't silence: two overlapping calls into the same instance throw InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. Reliably, every time, across repeated runs. EF Core is not quietly returning a wrong answer here — it is loudly refusing to proceed.
Which raises the obvious question: if HasOverlapAsync throws under the exact conditions the Tuesday rush created, why did the reservations still go through? Because of a line written months earlier, for an entirely reasonable reason. AvailabilityCache.IsFreeAsync had a try/catch wrapped around it:
public async Task<bool> IsFreeAsync(int toolId, DateTime start)
{
try
{
var repository = _scope.ServiceProvider.GetRequiredService<ReservationRepository>();
return !(await repository.HasOverlapAsync(toolId, start));
}
catch
{
return true; // "if the fast check fails, don't block the kiosk — let the write happen"
}
}
The reasoning, at the time, was about resilience: a transient database hiccup shouldn't freeze the kiosk's UI over what's meant to be a fast, best-effort check — fail open, and trust the actual reservation write to be the real guard against a genuine conflict. What made that trust misplaced is that the reservation write was never actually guarded — no unique constraint on (ToolId, slot), no transaction-level check, just an INSERT. At 2:45, Marcus's and Yuki's requests landed close enough together that both hit AvailabilityCache on different thread-pool threads, both reached the one frozen DbContext attempt three's held-open scope had been serving since the app started, and one of the two HasOverlapAsync calls collided with the other and threw exactly the exception above. The catch block did precisely what it was written to do — it "protected" the kiosk from an error by quietly reporting the slot as free, for the one request that most needed to hear the opposite.
Fixing this took two changes, not one: the using-scoped IServiceScopeFactory pattern from the previous chapter, so HasOverlapAsync runs against a fresh DbContext every single call instead of one shared instance — and a try/catch that logs and returns false (fail closed) rather than assuming the best. Neither fix alone would have been enough; the frozen context is what made the exception possible at all, and the fail-open catch is what turned "an exception happened" into "a double-booking shipped, silently, with a clean 200 response on both ends."
Independent of that incident, this chapter cements one more habit: every EF Core call in Bench is async — AnyAsync, ToListAsync, SaveChangesAsync, never their blocking twins — because inside a web API, calling the synchronous version doesn't just wait quietly for its own sake. It parks an entire thread-pool thread, doing nothing at all, for however long that database round trip takes, and that pool is the exact same shared resource every other request Bench is currently handling is also drawing from — more on exactly what that costs a few chapters ahead.