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.

Advanced135–175 minutesETag/message/offline boundary labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Propagate ServiceHub Revision through HTTP and message contracts as an opaque precondition.

02

Use If-Match/ETag-style semantics without treating a pre-read comparison as sufficient protection.

03

Carry expected versions into background jobs and reject stale work deterministically.

04

Design offline edit responses that expose conflict state without leaking sensitive values.

05

Distinguish row/aggregate concurrency from multi-row invariants that need transactions/isolation/constraints.

06

Avoid turning EF query filters or concurrency tokens into authorization claims.

Reproducible baseline

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

csharp · API/read-model contract
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

csharp · conceptual GET response
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));
csharp · write still relies on database concurrency predicate
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

csharp · message contract carries a precondition
public sealed record InspectWorkOrder(    long WorkOrderId,    Guid ExpectedRevision,    Guid MessageId);
csharp · worker rejects stale instructions
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.

json · example conflict envelope
{  "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

csharp · check-then-write race
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

  1. Expose one work order DTO including Revision and render the Revision as an opaque quoted ETag.
  2. Perform a correct If-Match update and return the new ETag.
  3. Repeat with the old ETag and verify 412/your chosen documented conflict response.
  4. Simulate a race after the pre-check and verify SaveChanges still catches it.
  5. Create a background-job record carrying ExpectedRevision; process it after a newer write and verify it is rejected as stale.
  6. Build a conflict envelope that names conflicting fields without exposing unauthorized database values.
  7. Write down one ServiceHub multi-row invariant and identify the database transaction/isolation/constraint mechanism needed beyond a row token.

Check your understanding

  1. Why must the version leave the DbContext in an API workflow?
  2. Does an If-Match pre-check eliminate the SaveChanges race?
  3. Is message idempotency the same as optimistic concurrency?
  4. Can a work-order row token enforce a count across many rows?
  5. Is a token an authorization credential?
  6. 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

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