Skip to content

Optimistic Concurrency — EF Core Row Versioning

Two users open the same order. Both change the status. Both click Save. Without concurrency control, the last write wins and the first change is silently lost. With IConcurrencyAware, the second save fails with HTTP 409 Conflict — the user can review the updated data and retry.

sequenceDiagram
    participant A as User A
    participant API as API
    participant DB as Database
    participant B as User B

    A->>API: GET /orders/42
    API->>DB: SELECT ... WHERE Id = 42
    DB-->>API: Order (stamp = "abc")
    API-->>A: { status: "Pending", stamp: "abc" }

    B->>API: GET /orders/42
    API->>DB: SELECT ... WHERE Id = 42
    DB-->>API: Order (stamp = "abc")
    API-->>B: { status: "Pending", stamp: "abc" }

    A->>API: PUT /orders/42 { status: "Confirmed", stamp: "abc" }
    API->>DB: UPDATE ... SET Status='Confirmed', Stamp='def' WHERE Id=42 AND Stamp='abc'
    DB-->>API: 1 row affected
    API-->>A: 200 OK { stamp: "def" }

    B->>API: PUT /orders/42 { status: "Cancelled", stamp: "abc" }
    API->>DB: UPDATE ... SET Status='Cancelled', Stamp='ghi' WHERE Id=42 AND Stamp='abc'
    DB-->>API: 0 rows affected (stamp is now "def")
    API-->>B: 409 Conflict

EF Core includes the ConcurrencyStamp in the WHERE clause of every UPDATE. If the stamp in the database differs from the value EF loaded, zero rows are affected and EF throws DbUpdateConcurrencyException. Granit maps this to HTTP 409 Conflict automatically.

No additional registration needed. IConcurrencyAware support is built into Granit.Persistence — already activated by AddGranitPersistence() and ApplyGranitConventions().

The three components:

ComponentRolePackage
IConcurrencyAwareEntity interface with ConcurrencyStamp propertyGranit
ConcurrencyStampInterceptorRegenerates stamp on every SaveChangesGranit.Persistence
ApplyGranitConventions()Configures .IsConcurrencyToken() automaticallyGranit.Persistence
EfCoreExceptionStatusCodeMapperMaps DbUpdateConcurrencyException to 409Granit.Persistence

Implement IConcurrencyAware on any entity that needs protection:

public class Order : AuditedEntity, IConcurrencyAware
{
public string Reference { get; set; } = string.Empty;
public OrderStatus Status { get; set; }
public decimal TotalAmount { get; set; }
// Auto-managed by ConcurrencyStampInterceptor
public string ConcurrencyStamp { get; set; } = string.Empty;
}

That’s it. No Fluent API, no attributes, no manual interceptor wiring. ApplyGranitConventions() auto-discovers all IConcurrencyAware entities and configures ConcurrencyStamp as VARCHAR(36) with .IsConcurrencyToken().

ConcurrencyStampInterceptor runs in the standard pipeline:

OrderInterceptorAction
1AuditedEntityInterceptorSets audit fields
2VersioningInterceptorSets version number
3ConcurrencyStampInterceptorRegenerates ConcurrencyStamp
4DomainEventDispatcherInterceptorCollects domain events
5SoftDeleteInterceptorConverts DELETE to soft delete

On EntityState.Added: sets the initial stamp (new GUID string). On EntityState.Modified: regenerates the stamp (new GUID string).

public sealed record OrderResponse(
Guid Id,
string Reference,
OrderStatus Status,
decimal TotalAmount,
string ConcurrencyStamp);

Use IConcurrencyStampRequest as a convention:

public sealed record UpdateOrderRequest(
OrderStatus Status,
string ConcurrencyStamp) : IConcurrencyStampRequest;

When the entity is loaded from the same DbContext, EF Core tracks OriginalValue automatically:

app.MapPut("/orders/{id:guid}", async (
Guid id,
UpdateOrderRequest request,
AppDbContext db,
CancellationToken ct) =>
{
Order? order = await db.Orders.FindAsync([id], ct);
if (order is null)
return TypedResults.NotFound();
order.Status = request.Status;
await db.SaveChangesAsync(ct); // stamp checked automatically
return TypedResults.Ok(order.ToResponse());
})
.Produces<OrderResponse>()
.ProducesProblem(StatusCodes.Status409Conflict);

Disconnected update (CQRS / new DbContext)

Section titled “Disconnected update (CQRS / new DbContext)”

When the entity was not loaded by the current DbContext, you must set the OriginalValue explicitly so EF Core knows what the client believes the stamp is:

app.MapPut("/orders/{id:guid}", async (
Guid id,
UpdateOrderRequest request,
AppDbContext db,
CancellationToken ct) =>
{
Order? order = await db.Orders.FindAsync([id], ct);
if (order is null)
return TypedResults.NotFound();
order.Status = request.Status;
// Tell EF Core what the client believes the current stamp is
db.Entry(order).Property(e => e.ConcurrencyStamp).OriginalValue
= request.ConcurrencyStamp;
await db.SaveChangesAsync(ct);
return TypedResults.Ok(order.ToResponse());
})
.Produces<OrderResponse>()
.ProducesProblem(StatusCodes.Status409Conflict);

EfCoreExceptionStatusCodeMapper automatically maps DbUpdateConcurrencyException to HTTP 409 Conflict with RFC 7807 ProblemDetails:

{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.8",
"title": "Conflict",
"status": 409,
"detail": "The resource was modified by another request. Reload and retry."
}

No custom exception handling needed — the Granit exception middleware handles it.

API consumers should implement this pattern:

  1. GET the resource (receive ConcurrencyStamp in response)
  2. PUT/PATCH with the stamp
  3. If 409: re-GET, show updated data to the user, let them retry
  4. If 200: success, update the local stamp
Use caseRecommendation
Orders, payments, invoicesYes — financial data loss is unacceptable
Appointments, bookingsYes — double-booking from concurrent edits
Shared configurationYes — admin panels with multiple operators
Inventory, stock levelsYes — concurrent decrements cause overselling
User profilesMaybe — low conflict probability, but protects against rare edge cases
Read-only / append-only entitiesNo — no concurrent writes to conflict
Audit log entriesNo — immutable, never updated

The overhead is negligible: one VARCHAR(36) column and one WHERE clause parameter per update.

Use SQLite for concurrency tests — the EF Core InMemory provider does not enforce concurrency tokens.

[Fact]
public async Task Concurrent_update_throws_DbUpdateConcurrencyException()
{
// Arrange — create entity via context1
using var factory = new SqliteDbContextFactory<OrderDbContext>();
using OrderDbContext context1 = factory.CreateContext();
Order order = new() { Reference = "ORD-001" };
context1.Orders.Add(order);
await context1.SaveChangesAsync();
// Load same entity in context2 (EF tracks OriginalValue automatically)
using OrderDbContext context2 = factory.CreateContext(ensureCreated: false);
Order? sameOrder = await context2.Orders.FindAsync(order.Id);
// Modify and save via context1 — stamp changes in DB
order.Status = OrderStatus.Confirmed;
await context1.SaveChangesAsync();
// Act — context2 still has the old stamp
sameOrder!.Status = OrderStatus.Cancelled;
// Assert
await Should.ThrowAsync<DbUpdateConcurrencyException>(
() => context2.SaveChangesAsync());
}
CategoryTypePackage
Entity interfaceIConcurrencyAwareGranit
DTO conventionIConcurrencyStampRequestGranit
InterceptorConcurrencyStampInterceptorGranit.Persistence
Auto-configApplyGranitConventions()Granit.Persistence
Exception mappingEfCoreExceptionStatusCodeMapperGranit.Persistence
  • Persistence — full interceptor pipeline, query filters, isolated DbContext
  • Exception Handling — RFC 7807 ProblemDetails, global error mapping
  • Idempotency — complementary pattern for preventing duplicate requests