Chapter 02 · DbContext Lifecycle, Configuration, Dependency Injection, Factories, and Pooling
DbContext as Unit of Work: Lifetime, Thread-Safety, Disposal, and Async Boundaries
Treat DbContext as a short-lived unit of work: prove thread-safety limits, async sequencing, tracker/connection behavior, and safe independent-context parallelism.
Learning outcomes
ServiceHub now has a reproducible EF Core lab, but a new web
endpoint and background importer are about to share data access.
The next failure class is not SQL syntax—it is
lifetime. A DbContext represents
one short unit of work and maintains mutable tracking state.
Treating it as a global database client creates stale data,
unbounded tracking, and unsupported concurrent access.
Explain DbContext as a short-lived unit of work and identity map rather than a process-wide database session.
Prove that a DbContext is not thread-safe and that one async operation must complete before the same context is reused.
Observe ContextId, ChangeTracker state, generated SQL, and ADO.NET connection state across a unit of work.
Distinguish disposing a context from closing a permanently-held database connection.
Repair parallel work by sequencing operations or creating independent contexts.
Keep the ServiceHub solution, ServiceHubContext,
deterministic WorkOrders, SQLite database, logging categories,
migrations, and reset rules from Chapter 01. This lesson
changes lifetime, not the domain model.
1. One context, one coherent unit of work
EF Core describes a DbContext lifetime as beginning
with creation and ending with disposal. During that interval the
context discovers or reuses its model, tracks materialized
entities, performs identity resolution for tracking queries,
records changes, and coordinates SaveChanges. This
mutable state is exactly why a context is useful—and exactly why
it should not live forever.
A unit of work should have a business boundary: handle one HTTP request that edits a work order, process one queue message, run one batch partition, or perform one test case. A longer lifetime makes previously loaded entities linger in the identity map and grows the tracker. A shorter lifetime than the business operation can make atomic updates harder to reason about. There is no universal duration in milliseconds; the boundary is semantic.
| Boundary | Healthy context pattern | Risky pattern |
|---|---|---|
| HTTP request | One scoped context for the request when one unit of work fits the request | Static/singleton context shared by all requests |
| Worker message | New context per message or per explicit unit | One context stored on the BackgroundService for hours |
| Parallel fan-out | Independent context per branch | Task.WhenAll over one context |
| Test | Fresh context/database state per test boundary | Cross-test context reused with tracked entities |
2. A context is not thread-safe
EF Core does not support multiple parallel operations on the
same DbContext. This includes two async queries in
flight at once and concurrent access from different threads. EF
has safety checks that detect many violations and commonly
throws InvalidOperationException with wording such
as “A second operation was started on this context instance
before a previous operation completed.” The exact message can
vary; the invariant does not.
await using var context = CreateContext();var openTask = context.WorkOrders .Where(w => w.Status == WorkOrderStatus.Open) .ToListAsync();var urgentTask = context.WorkOrders .Where(w => w.Priority >= 4) .ToListAsync();await Task.WhenAll(openTask, urgentTask); // unsupported: same context concurrently
Do not “fix” this with a lock around random EF
calls. Either await one operation before starting the next, or
create independent contexts for genuinely independent parallel
units. If EF throws an
InvalidOperationException caused by misuse,
Microsoft warns that such exceptions can leave the context in an
unrecoverable state; dispose it and create a fresh unit.
await using var context = CreateContext();var open = await context.WorkOrders.Where(w => w.Status == WorkOrderStatus.Open).ToListAsync();var urgent = await context.WorkOrders.Where(w => w.Priority >= 4).ToListAsync();
3. Independent parallel work needs independent contexts
Parallelism is valid when the work is independent. The boundary
moves from “one task equals one query” to “one task equals one
unit of work with its own context.” Later lessons introduce
IDbContextFactory<TContext> for exactly this
shape.
async Task<int> CountOpenAsync(IDbContextFactory<ServiceHubContext> factory){ await using var db = await factory.CreateDbContextAsync(); return await db.WorkOrders.CountAsync(w => w.Status == WorkOrderStatus.Open);}async Task<int> CountUrgentAsync(IDbContextFactory<ServiceHubContext> factory){ await using var db = await factory.CreateDbContextAsync(); return await db.WorkOrders.CountAsync(w => w.Priority >= 4);}var counts = await Task.WhenAll(CountOpenAsync(factory), CountUrgentAsync(factory));
Separate contexts mean separate trackers and separate ADO.NET command execution. They do not automatically create a distributed transaction or a consistent cross-query snapshot. If both operations must be atomic or observe one transaction, design the transaction explicitly rather than reaching for parallelism.
4. Async boundaries must be real boundaries
ToListAsync, SingleAsync,
SaveChangesAsync, and other EF async methods return
tasks. Await them before the context is reused. “Fire and
forget” EF work is especially dangerous in request code because
the request scope may be disposed while the operation is still
running.
public async Task CloseWorkOrderAsync(int id, CancellationToken cancellationToken){ await using var db = await _factory.CreateDbContextAsync(cancellationToken); var workOrder = await db.WorkOrders.SingleAsync(w => w.Id == id, cancellationToken); workOrder.Status = WorkOrderStatus.Closed; await db.SaveChangesAsync(cancellationToken);}
A cancellation token asks the operation to stop. Whether a command was sent, a transaction exists, or a commit happened is provider/database dependent. Later transaction lessons distinguish cancellation, timeout, transient failure, and ambiguous commit outcomes.
5. Observe context identity, tracker growth, and connection state
Use evidence rather than lifetime folklore.
ContextId identifies a context instance.
ChangeTracker.Entries() reveals tracked entities.
For relational providers,
Database.GetDbConnection().State exposes the
ADO.NET connection state. EF normally opens a connection
immediately before a database operation and closes it afterward
when EF owns the open/close cycle.
await using var db = CreateContext();Console.WriteLine($"Context: {db.ContextId}");Console.WriteLine($"Before query: {db.Database.GetDbConnection().State}");Console.WriteLine($"Tracked before: {db.ChangeTracker.Entries().Count()}");var row = await db.WorkOrders.OrderBy(w => w.Id).FirstAsync();Console.WriteLine($"After query: {db.Database.GetDbConnection().State}");Console.WriteLine($"Tracked after: {db.ChangeTracker.Entries().Count()}");Console.WriteLine(db.ChangeTracker.DebugView.ShortView);
For the default SQLite file connection, expect the connection to
be closed outside command execution while the context itself
remains alive. If you explicitly call
OpenConnection, you changed connection
ownership/lifetime and must restore that state appropriately.
DbContext lifetime and connection lifetime are related but not
identical.
6. Disposal ends the unit; it does not “delete the database session forever”
using/await using ensures context
disposal. Disposal stops tracking and releases owned resources.
It does not imply that a pooled driver connection is physically
destroyed, and it does not mean a context had one network
connection open for its entire lifetime. In server databases,
ADO.NET connection pooling normally operates below EF Core;
SQLite uses a different embedded access model, but the
conceptual separation remains.
Do not manually dispose a context injected by DI before its
owning scope ends unless you explicitly own it. Conversely, a
context created by IDbContextFactory is your
responsibility to dispose because the application service
provider did not create that instance as a scoped service.
7. Hands-on lab: reproduce the concurrency failure and repair it
Use a disposable copy of servicehub-lab.db. The
goal is to make the unsupported state visible, not merely read a
warning.
dotnet --versiondotnet tool restoredotnet build# Run the Chapter 02 lifetime probe in Development configuration.# 1) print ContextId / connection state / tracker count# 2) execute the intentionally parallel same-context example# 3) replace it with sequential awaits# 4) run parallel work with separate factory-created contexts
Verification checklist
-
The same context prints one stable
ContextIdfor its lifetime. - A tracking query increases tracked-entry count.
- The intentionally concurrent same-context example fails or is explicitly identified as unsupported even if a race is not reproduced on every run.
- The sequential version succeeds.
- The parallel factory version uses different context IDs.
- No lab process touches a non-ServiceHub database.
Check your understanding
- Why is DbContext mutable state useful and dangerous at the same time?
- Can two awaited queries run sequentially on one context?
- Why is Task.WhenAll over two queries from one context invalid?
- Does disposing a context prove the underlying driver destroyed a physical connection?
- If an EF misuse InvalidOperationException occurs, should the application keep the context and retry arbitrary work?
Review the answers
Tracking and identity resolution make a unit of work coherent, but that same mutable state cannot safely be shared across unrelated or concurrent work.
Yes. Complete one operation before beginning the next.
Both operations can overlap and mutate/use the same non-thread-safe context services and tracker.
No. Driver connection pooling and EF context lifetime are separate concerns.
No. Dispose the misused context and begin a fresh unit of work after correcting the programming error.
8. Production judgment and bridge
Default to a short context per business unit, await every operation before reuse, and create independent contexts for independent parallel work. Monitor long request/message durations, tracker size, query latency, pool pressure, and repeated concurrency exceptions as symptoms of boundary mistakes. Do not disable EF thread-safety checks merely to silence an error.
The next lesson asks a different question: once lifetime is
correct, where should provider, connection, logging, timeout,
and environment configuration live? That leads to
DbContextOptions<TContext>,
OnConfiguring, and externally composed options.
Authoritative references
- DbContext Lifetime, Configuration, and Initialization — official lifetime, thread-safety, disposal, factory, and configuration guidance
- Async programming with EF Core — async query/save rules and cancellation
- Advanced Performance Topics — context pooling versus connection pooling and thread-safety checks
- Change Tracking in EF Core — tracker and entity-state fundamentals