Chapter 13 · Optimistic Concurrency and Conflict Resolution
Concurrency Across Aggregate Boundaries, APIs, Background Jobs, and Offline Workflows
Propagate concurrency versions across HTTP, messages, background jobs, offline edits, and aggregate boundaries without pretending one row token protects multi-row invariants.
Learning outcomes
Most real concurrency windows are longer than one
DbContext. A browser may edit for minutes, a mobile
client may be offline for hours, and a queue message may be
delivered after newer state has already committed. The
concurrency version therefore has to cross the application
boundary as data, while authorization and cross-row correctness
remain separate concerns.
Propagate ServiceHub Revision through HTTP and message contracts as an opaque precondition.
Use If-Match/ETag-style semantics without treating a pre-read comparison as sufficient protection.
Carry expected versions into background jobs and reject stale work deterministically.
Design offline edit responses that expose conflict state without leaking sensitive values.
Distinguish row/aggregate concurrency from multi-row invariants that need transactions/isolation/constraints.
Avoid turning EF query filters or concurrency tokens into authorization claims.
Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, and the existing application-managed Guid Revision token. SQL Server rowversion and PostgreSQL xmin are optional provider comparisons, not mandatory infrastructure. EF Core 11 previews are excluded.
1. Revision belongs in the contract, not the DbContext lifetime
public sealed record WorkOrderDto( long Id, string WorkOrderNumber, string Summary, Guid Revision);
The DbContext remains short-lived. The client
carries the version it read. When it later submits a mutation,
the server uses that version as the concurrency precondition for
the new unit of work.
2. HTTP: expose an opaque ETag and require If-Match for writes
var order = await db.WorkOrders.AsNoTracking() .SingleAsync(x => x.Id == id, ct);http.Response.Headers.ETag = $"\"{order.Revision:N}\"";return Results.Ok(new WorkOrderDto( order.Id, order.WorkOrderNumber, order.Summary, order.Revision));
var rawIfMatch = request.Headers["If-Match"].ToString().Trim();if (rawIfMatch.Length < 2 || rawIfMatch[0] != '\"' || rawIfMatch[^1] != '\"' || !Guid.TryParseExact(rawIfMatch[1..^1], "N", out var expectedRevision)){ return Results.BadRequest("A quoted ServiceHub revision ETag is required.");}var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);// Fast feedback only; a race can still occur after this comparison.if (order.Revision != expectedRevision) return Results.StatusCode(StatusCodes.Status412PreconditionFailed);order.ReviseSummary(command.Summary);db.Entry(order).Property(x => x.Revision).OriginalValue = expectedRevision;order.AdvanceRevision();try{ await db.SaveChangesAsync(ct);}catch (DbUpdateConcurrencyException){ return Results.StatusCode(StatusCodes.Status412PreconditionFailed);}
The in-memory equality check improves the common stale-request path but is not the concurrency guarantee. Another writer can commit between that check and SaveChanges; only the database conditional UPDATE closes that race. If the Revision does not change for every field represented by the HTTP response, do not advertise it as a strong representation ETag; widen the version scope or use a separate opaque precondition token.
3. Background jobs/messages need the expected version too
public sealed record InspectWorkOrder( long WorkOrderId, Guid ExpectedRevision, Guid MessageId);
var order = await db.WorkOrders.SingleAsync(x => x.Id == job.WorkOrderId, ct);if (order.Revision != job.ExpectedRevision){ await staleJobSink.RecordAsync(job.MessageId, order.Revision, ct); return;}order.ReviseSummary(BuildInspectionSummary(order));db.Entry(order).Property(x => x.Revision).OriginalValue = job.ExpectedRevision;order.AdvanceRevision();await db.SaveChangesAsync(ct); // still catch a race here
A queue's delivery retry is not permission to reapply old intent to new state. Idempotency (same message delivered twice) and concurrency (state changed since message was created) are related but distinct.
4. Offline editing: present a three-way conflict, not a generic 500
An offline client can send its base revision plus proposed changes. On conflict, the server can return a structured response containing a new opaque revision and conflict metadata. Avoid returning fields the caller is not authorized to read merely because EF can retrieve them for merge logic.
{ "type": "work-order-version-conflict", "workOrderId": 201, "expectedRevision": "...", "currentRevision": "...", "conflictingFields": ["summary"]}
The response communicates a concurrency problem; authorization decides which current values, if any, may be returned for user-assisted merge.
5. One row token cannot enforce a multi-row invariant
Suppose ServiceHub must enforce “a technician cannot have more than N active emergency assignments.” A token on one work order only detects changes to that row. Two transactions can each update different work orders and jointly violate the count even though neither sees a token mismatch.
| Invariant scope | Concurrency token enough? | Additional mechanism |
|---|---|---|
| One work-order summary | Usually | Token + domain merge policy. |
| Work order + tightly owned row updated in same aggregate transaction | Maybe, if version truly covers aggregate mutation | Aggregate version + transaction/constraints as needed. |
| Count across many work orders | No | Database constraint/design, appropriate isolation/locking, or serialized coordinator. |
| Uniqueness | Do not reimplement with token | Unique constraint/index is authoritative. |
| Authorization/tenant access | No | Authorization + tenant isolation/constraints; token is not identity/permission. |
6. Deliberately wrong: read, compare, then trust
var currentRevision = await db.WorkOrders .Where(x => x.Id == id) .Select(x => x.Revision) .SingleAsync(ct);if (currentRevision == expectedRevision){ // BUG: another writer can commit here. await db.WorkOrders .Where(x => x.Id == id) .ExecuteUpdateAsync(s => s.SetProperty(x => x.Revision, Guid.NewGuid()), ct);}
The repair is a single conditional write predicate (or normal tracked concurrency token) that includes the expected version, then checks affected rows. This is the database equivalent of compare-and-swap.
7. Hands-on boundary lab
- Expose one work order DTO including Revision and render the Revision as an opaque quoted ETag.
- Perform a correct If-Match update and return the new ETag.
- Repeat with the old ETag and verify 412/your chosen documented conflict response.
- Simulate a race after the pre-check and verify SaveChanges still catches it.
- Create a background-job record carrying ExpectedRevision; process it after a newer write and verify it is rejected as stale.
- Build a conflict envelope that names conflicting fields without exposing unauthorized database values.
- Write down one ServiceHub multi-row invariant and identify the database transaction/isolation/constraint mechanism needed beyond a row token.
Check your understanding
- Why must the version leave the DbContext in an API workflow?
- Does an If-Match pre-check eliminate the SaveChanges race?
- Is message idempotency the same as optimistic concurrency?
- Can a work-order row token enforce a count across many rows?
- Is a token an authorization credential?
- Why return an opaque version instead of internal provider details?
Review the answers
The edit can outlive the short-lived context, so the client must carry the version precondition.
No. Keep the database conditional write/concurrency token.
No. Idempotency handles repeat delivery; concurrency handles state changed since the message was based on it.
No. Use database constraints/design and transaction/isolation/coordination appropriate to the invariant.
No. It proves version knowledge, not identity or permission.
It keeps HTTP/message contracts independent of rowversion/xmin/Guid implementation and avoids coupling clients to provider storage.
8. Production judgment and bridge
Versions are cross-boundary preconditions, not long-lived contexts. Make conflict status explicit in APIs/messages, keep authorization separate, and use database-level mechanisms for invariants wider than the versioned aggregate. Lesson 5 turns these semantics into deterministic tests and ensures business conflicts do not get mixed with transient-failure retry policy.
Authoritative references
- Handling Concurrency Conflicts - EF Core — database-side optimistic concurrency foundation.
- RFC 9110 HTTP Semantics — ETag and conditional request semantics such as If-Match.
- ASP.NET Core EF concurrency tutorial — web request conflict handling with EF Core.
- Using Transactions - EF Core — transaction scope for wider invariants.
- ExecuteUpdate/Delete - EF Core — single conditional set-based write + rows affected pattern.
- Global Query Filters - EF Core — why query filters are data filtering, not a complete authorization boundary.