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.

Intermediate85–105 minuteslifetime + concurrency failure labEF Core 10.0.11 · .NET 10SQLite provider 10.0.11 baselineLast reviewed: August 2026

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.

01

Explain DbContext as a short-lived unit of work and identity map rather than a process-wide database session.

02

Prove that a DbContext is not thread-safe and that one async operation must complete before the same context is reused.

03

Observe ContextId, ChangeTracker state, generated SQL, and ADO.NET connection state across a unit of work.

04

Distinguish disposing a context from closing a permanently-held database connection.

05

Repair parallel work by sequencing operations or creating independent contexts.

Chapter 01 continuity

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.

csharp · intentionally broken parallel use of one context
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.

csharp · safe sequential reuse inside one 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.

csharp · parallel branches with separate contexts
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.

csharp · correct cancellation-aware request work
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);}
Cancellation is not rollback magic

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.

csharp · lifetime evidence probe
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.

text · lab sequence
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 ContextId for 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

  1. Why is DbContext mutable state useful and dangerous at the same time?
  2. Can two awaited queries run sequentially on one context?
  3. Why is Task.WhenAll over two queries from one context invalid?
  4. Does disposing a context prove the underlying driver destroyed a physical connection?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n