Table of Contents

Optimistic concurrency

One declaration, one exception, four native mechanisms. Declare a token on the model and every write on that entity means: a stale write must fail — never silently win.

public sealed class Ledger : IEntity<Guid>
{
    public Guid Id { get; set; }
    public decimal Balance { get; set; }
    [ConcurrencyToken] public long Version { get; set; }   // int, long — or string on Cosmos (the _etag)
    public Guid GetKey() => Id;
    public void SetKey(Guid key) => Id = key;
}
var first  = await repo.GetAsync(id);    // Version == 3
var second = await repo.GetAsync(id);    // Version == 3 — a concurrent reader

first!.Balance = 150m;
await repo.ModifyAsync(first);
await uow.CommitAsync();                 // wins; Version becomes 4 (on the entity too)

second!.Balance = 999m;
await repo.ModifyAsync(second);
await uow.CommitAsync();                 // throws ConcurrencyConflictException

ConcurrencyConflictException carries Expected and Affected and one instruction: reload, reapply, retry. The stale writer's data never reached the store.

The four mechanisms

Store A Modify becomes A lost race is
Relational UPDATE … SET …, version = @next WHERE key = @k AND version = @old 0 rows affected → the whole flush rolls back
Cosmos DB replace with If-Match: <etag> (the token is a string member carrying _etag) HTTP 412 → conflict
Cassandra a lightweight transaction: UPDATE … IF version = @old (new rows: INSERT … IF NOT EXISTS) [applied] = false → conflict
MongoDB ReplaceOne filtered on {_id, version} with the bump in the document matched 0 → conflict

Deletes are protected the same way (DELETE … WHERE/IF version = @old), so removing an entity someone else already changed is also a conflict — deliberately.

The costs, stated plainly

  • Relational / MongoDB / Cosmos — effectively free; the condition rides the write it was already doing.
  • Cassandra — an LWT is a Paxos round: significantly more expensive than a plain upsert. That is the price Cassandra charges for compare-and-set semantics; the token declaration is your explicit opt-in to pay it, per entity. Consequences the provider enforces honestly: a token write refuses to join a LOGGED BATCH (Cassandra restricts conditional batches), and entities without a token keep plain last-write-wins upserts — the default is the store's own semantics.

Version bookkeeping

The engine owns the token: 0/unset means never persisted (first write stamps 1), every successful write bumps it and writes the bump back onto the entity, so the instance you hold stays current after CommitAsync. Never set the token yourself.

Retry pattern

for (var attempt = 0; ; attempt++)
{
    var entity = await repo.GetAsync(id) ?? throw new NotFoundException();
    Apply(entity, change);
    await repo.ModifyAsync(entity);
    try { await uow.CommitAsync(); break; }
    catch (ConcurrencyConflictException) when (attempt < 3) { /* loop: reload + reapply */ }
}

Reload-reapply-retry is the correct loop for business operations; the exception exists so you decide the merge policy instead of the store deciding it for you (by overwriting).