Two requests can read the same row and both change DisplayName based on that state. Without a concurrency check, the later save can silently overwrite the earlier change. That is a lost update.
Use an optimistic concurrency token when an update must be based on the version the caller originally read.
For SQL Server, a rowversion column is a compact way to detect changes to the complete row:
public sealed class Customer
{
public Guid Id { get; private set; }
public string DisplayName { get; private set; } = string.Empty;
public byte[] Version { get; private set; } = [];
public void Rename(string displayName) => DisplayName = displayName;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.Property(customer => customer.Version)
.IsRowVersion();
}
EF Core includes the original token in the generated update condition. Conceptually, the database receives an update like this:
UPDATE Customers
SET DisplayName = @displayName
WHERE Id = @id AND Version = @expectedVersion;
If another operation has already updated the row, the version no longer matches. The update affects zero rows, and SaveChangesAsync throws DbUpdateConcurrencyException instead of overwriting the newer state. EF Core also reports DbUpdateConcurrencyException if the row is deleted after it was read but before the save.
The expected token must be the version the caller read, not the version loaded at the start of the update request. Pass it through the edit boundary and set it as the original value before saving:
public async Task<UpdateCustomerResult> RenameAsync(
Guid customerId,
string displayName,
byte[] expectedVersion,
CancellationToken cancellationToken)
{
Customer? customer = await db.Customers.SingleOrDefaultAsync(
item => item.Id == customerId,
cancellationToken);
if (customer is null)
{
return UpdateCustomerResult.NotFound;
}
customer.Rename(displayName);
db.Entry(customer)
.Property(item => item.Version)
.OriginalValue = expectedVersion;
try
{
await db.SaveChangesAsync(cancellationToken);
return UpdateCustomerResult.Saved;
}
catch (DbUpdateConcurrencyException)
{
return UpdateCustomerResult.Conflict;
}
}
For an HTTP API, expose the version as an opaque ETag and require the client to return it with If-Match. A stale token can then map to 412 Precondition Failed. Do not make the database column itself part of the public request model.
Once EF Core reports the conflict, the application still has to decide what happens next:
- reject the write, reload the current state, and let the user try again
- merge changes only when the domain has a safe merge rule
- retry automated work only after re-reading the current state and recalculating the change
Do not catch DbUpdateConcurrencyException and immediately repeat the same save. That bypasses the decision the token was meant to protect.
rowversion is specific to SQL Server. Other providers may offer a different database-generated value, or you can manage a GUID or numeric token in application code. The check stays the same: save only when the stored version matches the version on which the change was based.
A row-level token does not protect invariants spanning several rows, coordinate external side effects, or prevent duplicate inserts. Those cases need an appropriate transaction, conditional operation, unique constraint, idempotency boundary, or workflow design.
Skip the token when last-write-wins behavior is acceptable. Otherwise, make the conflict path part of the update contract.