One unit, bounded use
AddDbContext uses scoped by default. In a simple HTTP API, that provides one instance per request and a sequential unit of work. It does not make two queries started in parallel within one request safe. DbContext does not support concurrent operations on one instance, including asynchronous queries started before awaiting the preceding operation.
Await one operation before the next or use separate contexts when parallelism is intentional. Separate contexts do not by themselves guarantee a consistent data snapshot across queries. If the unit differs from the request, a factory can create it explicitly; code creating the instance this way must also dispose it.
Tracking is neither commit nor freshness
An entity returned by a tracking query is tracked by the context. Changes can be detected when SaveChanges is called. A C# assignment is not immediately a SQL write. With AsNoTracking, the query does not put the object under that tracking; changing a property and calling SaveChanges without attaching it does not imply persistence of that change.
Tracking also has a freshness boundary: if the entity is already in the context, a query can reuse the same instance without overwriting current and original values. A separate no-tracking read or explicit reload can be needed, depending on intent. Do not use a long-lived context as proof that local data remains current.
Atomicity has a boundary
When the provider supports transactions and default behavior is unchanged, one SaveChanges call groups its changes in a transaction. If one change fails, changes from that call are rolled back. Two calls on the same context without a shared explicit transaction do not automatically receive the same combined guarantee.
An order write can remain committed before a receipt write fails. A POST sent between them does not automatically join the database transaction either. The design must decide which local writes require atomicity and how to reconcile external effects without claiming local rollback undoes confirmed remote work.
Compare the version that was read
A concurrency token adds the original version to the update or delete condition. If the row still has that version, the write can proceed. If another session changed it and renewed the token, the condition can affect zero rows and SaveChanges reports DbUpdateConcurrencyException. Detection does not decide which change should prevail.
The example uses SQLite with an application-managed Guid. ConcurrencyCheck marks the property but does not generate its next value. Each relevant write must renew Version. This differs from a SQL Server-managed rowversion column. A unique-key violation on insertion should not automatically be classified as this update-conflict exception either.
Resolution requires a new decision
A conflict exposes original values, values proposed by the application and current database values. Comparing them helps choose rejection, reload or a merge allowed by the rules. Merely copying the current token to force values calculated from an older version can lose the change just detected.
In the stock case, an initial read of 8 permits proposing removal of five. If the current quantity is already 4, that same decision is no longer valid. The example’s conflict-reload branch signals the need for review rather than performing a blind retry. The caller still needs an error contract and a bounded policy for further attempts.
Savepoints depend on provider and configuration
When SaveChanges runs inside an already open transaction, EF can use a savepoint to restore state preceding the save attempt. That does not mean returning to any old commit or undoing HTTP calls. The boundary remains the local transaction and the point created within it.
SQL Server has a concrete exception: with MARS enabled, EF does not create those savepoints even when MARS is not actively used. After an error, the transaction can be left in an unknown state. Recovery must consider that configuration before continuing to use the transaction as though it had been restored.
Read the service and its limits
The excerpt assumes the EF Core 10 SQLite provider, an initialized table and a host-registered factory. Reserve creates a context, awaits the read, checks quantity and attempts a conditional write. The context is disposed at the end of the unit. Cancellation and other exceptions are not converted into success by this method; only concurrency conflicts have the specific shown return.
The sequence is not a complete reservation service. It does not present authorization, an HTTP contract, migration or recovery of external effects. Questions use illustrative premises and states to select outcomes and decisions. Assessment requires neither running the excerpt, creating a database nor repeating laboratories.
// Service/model excerpt for EF Core 10; not a complete host.
// Preconditions: SQLite EF Core 10 provider, initialized Items table,
// and IDbContextFactory<StockDb> registered by the host.
// Each writer renews the application-managed Version for stock changes.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
public sealed class StockService(IDbContextFactory<StockDb> factory)
{
public async Task<string> Reserve(int id, int units, CancellationToken ct)
{
if (units <= 0) return "invalid-units";
await using var db = await factory.CreateDbContextAsync(ct);
var item = await db.Items.SingleOrDefaultAsync(x => x.Id == id, ct);
if (item is null) return "missing";
if (item.Available < units) return "insufficient";
item.Available -= units;
item.Version = Guid.NewGuid();
try
{
await db.SaveChangesAsync(ct);
return "saved";
}
catch (DbUpdateConcurrencyException)
{
return "conflict-reload";
}
}
}
public sealed class StockDb(DbContextOptions<StockDb> options) : DbContext(options)
{
public DbSet<StockItem> Items => Set<StockItem>();
}
public sealed class StockItem
{
public int Id { get; set; }
public int Available { get; set; }
[ConcurrencyCheck]
public Guid Version { get; set; }
}
Start with Available=8 and Version=A. Without a concurrent write, reserving five units can save 3 with a new Version. If another session saves 4 with Version=B before the conditional update, this attempt encounters a conflict. The conflict-reload branch does not repeat the old proposal.
Common pitfalls
Using Task.WhenAll on one context; confusing context lifetime with a shared transaction; expecting automatic persistence of untracked objects; only refreshing the original token to force old values; assuming automatic rowversion in SQLite.
Related topics: Dependency lifetimes and configuration · Outbound HTTP, caching and resilience · Test scope and integration evidence
A context coordinates objects in a unit, a transaction bounds atomic changes and a token detects concurrent versions. Each boundary needs its own decision; none automatically includes a remote HTTP effect.
Reference: DbContext operation boundaries · ASP.NET Core 10.0 / .NET 10 / C# 14