Chapter 13 · Optimistic Concurrency and Conflict Resolution

Resolve DbUpdateConcurrencyException with Client Wins, Store Wins, or Field-Level Merge

Use DbUpdateConcurrencyException entries and property snapshots to implement store-wins, client-wins, and field-level merge policies with bounded retries.

Advanced145–190 minutesresolution-policy + bounded retry labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

A concurrency exception is not a request to “retry until it works.” It is a request to decide what the application means when the proposal, the original version, and the current database version disagree. ServiceHub needs different policies for different fields and workflows.

01

Inspect exception Entries, CurrentValues, OriginalValues, and GetDatabaseValuesAsync.

02

Implement store-wins by discarding the stale proposal and reloading the database state.

03

Implement client-wins consciously by refreshing originals before a bounded retry.

04

Implement a three-way field-level merge using original/proposed/database values.

05

Handle row deletion and irreconcilable conflicts explicitly.

06

Cap retries and emit conflict telemetry without logging confidential payloads.

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. The three value sets form a merge problem

Value set Meaning Source
Original What this context read/believed when tracking started. entry.OriginalValues
Proposed/current What this client now wants to persist. entry.CurrentValues
Database What is stored now after the competing writer. await entry.GetDatabaseValuesAsync()

A field-level merge is analogous to a three-way source-control merge: original is the common base, proposed is one branch, database is the other. If only one side changed a property, merging can be safe. If both sides changed it differently, the domain must decide.

2. Store wins: discard the stale proposal

csharp · reload conflicting entries
catch (DbUpdateConcurrencyException ex){    foreach (var entry in ex.Entries)    {        await entry.ReloadAsync(ct);        // Entry is now aligned to the current store row and typically Unchanged.    }    return Conflict("The work order changed; current server state was reloaded.");}

Store-wins is appropriate when the attempted edit is low value, automatically recomputable, or the user should review the latest state before editing again. It is not “successful save”; the client proposal was discarded and that outcome must be visible.

3. Client wins: refresh the precondition, then retry deliberately

csharp · bounded client-wins retry
for (var attempt = 1; attempt <= 2; attempt++){    try    {        await db.SaveChangesAsync(ct);        break;    }    catch (DbUpdateConcurrencyException ex) when (attempt < 2)    {        foreach (var entry in ex.Entries)        {            var databaseValues = await entry.GetDatabaseValuesAsync(ct);            if (databaseValues is null)                throw new WorkOrderDeletedConflictException();            // Keep proposed values; accept the database version as the new comparison base.            entry.OriginalValues.SetValues(databaseValues);            // Publish a fresh application-managed version for the retry.            entry.CurrentValues[nameof(WorkOrder.Revision)] = Guid.NewGuid();        }    }}

This intentionally overwrites conflicting database fields with the client's current proposal if the second attempt succeeds. Use it only when the business policy explicitly says the client has authority. Refreshing originals is what changes the next predicate; simply retrying the exact stale predicate achieves nothing.

4. Field-level merge: compare each property to the common base

csharp · three-way merge skeleton
var proposed = entry.CurrentValues;var original = entry.OriginalValues;var database = await entry.GetDatabaseValuesAsync(ct)    ?? throw new WorkOrderDeletedConflictException();var mergeableProperties = new[]{    nameof(WorkOrder.Summary)    // Add only domain fields whose merge policy is explicitly defined.};foreach (var propertyName in mergeableProperties){    var was = original[propertyName];    var mine = proposed[propertyName];    var theirs = database[propertyName];    var mineChanged = !Equals(mine, was);    var theirsChanged = !Equals(theirs, was);    if (!mineChanged && theirsChanged)        proposed[propertyName] = theirs;       // take store-only edit    else if (mineChanged && theirsChanged && !Equals(mine, theirs))        throw new WorkOrderMergeConflictException(propertyName, mine, theirs);}// Generated/audit/FK fields are not implicitly merged by this allow-list.entry.OriginalValues.SetValues(database);entry.CurrentValues[nameof(WorkOrder.Revision)] = Guid.NewGuid();await db.SaveChangesAsync(ct);

This generic shape is useful for teaching, but production merge policy should usually be explicit per domain field. A summary decision, technician assignment, approval status, and financial amount do not have the same merge semantics.

5. The backing field does not change the concurrency method

ServiceHub maps Summary through the _summary backing field. Conflict resolution should still use the mapped EF property metadata named Summary, as Chapter 11 established. Do not attempt entry.Property("_summary") unless the field itself was separately configured as a field-only property.

csharp · mapped property values work with encapsulation
var proposedSummary = entry.CurrentValues[nameof(WorkOrder.Summary)];var originalSummary = entry.OriginalValues[nameof(WorkOrder.Summary)];var databaseSummary = databaseValues[nameof(WorkOrder.Summary)];

6. Deleted rows require a different decision

GetDatabaseValuesAsync can return null when the row no longer exists. “Client wins” is no longer merely an update decision: recreating a deleted aggregate may violate identifiers, uniqueness, workflow history, foreign keys, or legal retention rules.

Do not silently resurrect

Treat deletion-vs-edit as an explicit domain conflict. Returning HTTP 409/412, asking the user to re-create a new work order, or rejecting a background job may all be safer than inserting a replacement row.

7. Hands-on policy lab

  1. Reset SQLite and prepare two contexts reading the same work order/revision.
  2. Make writer A change Summary and commit.
  3. Make writer B change the same Summary and capture DbUpdateConcurrencyException.
  4. Run store-wins and verify B's proposed summary is discarded.
  5. Reset; run bounded client-wins and verify the final database summary/revision.
  6. Reset; create non-overlapping changes in a teaching pair of mapped fields and run the three-way merge.
  7. Create a same-field divergent edit and verify your merge policy surfaces an irreconcilable conflict rather than picking arbitrarily.
  8. Delete the row from writer A and verify B handles GetDatabaseValuesAsync == null.

Check your understanding

  1. What are the three inputs to a field-level merge?
  2. What does store-wins do to the client proposal?
  3. What must client-wins refresh before retry?
  4. Why generate a fresh Revision for the retry?
  5. What does null GetDatabaseValues mean?
  6. Why cap retries?
Review the answers

Original values, proposed/current values, and current database values.

It discards/replaces it with the latest store state.

Original values, so the next concurrency predicate compares against the current database version.

A successful retry must publish a new version that invalidates other stale writers.

The row no longer exists, so update resolution becomes a deletion/recreation domain decision.

Repeated new conflicts can otherwise cause an unbounded loop and hide sustained contention/business disagreement.

8. Production judgment and bridge

Conflict resolution belongs close to domain/application policy, not in a generic “retry all concurrency exceptions” utility. Emit structured telemetry such as entity type, operation, attempt count, and conflict field names—without dumping confidential values. Lesson 4 moves that policy across process boundaries where versions become ETags, message preconditions, and offline-edit contracts.

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