Overview
This implementation pattern prevents the “last-one-wins” problem in Entity Framework Core (EF Core) when multiple users update the same data at the same time. For data where consistency is critical, such as inventory management or seat reservations, we use the [Timestamp] attribute for row versioning. This triggers a DbUpdateConcurrencyException when a conflict occurs.
Specifications (Input/Output)
- Input: Product stock ID and the updated quantity.
- Output: A completion message for successful updates. For conflicts, the application catches the exception and displays the current database value.
- Prerequisites: .NET 6.0 or higher. Note: This example uses an In-Memory database, but real production environments should use a database that supports
rowversion, such as SQL Server.
Basic Usage
Add the [Timestamp] attribute to a byte[] property in your entity. Wrap the SaveChangesAsync call in a try-catch block.
public class Stock
{
public int Id { get; set; }
public int Quantity { get; set; }
[Timestamp] // A version column that updates automatically
public byte[] RowVersion { get; set; }
}
// Save process
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Logic to handle the conflict
}
Full Code Example
The following console application simulates a scenario where User A and User B try to change the stock of the same product at the same time in a warehouse system. You will need the Microsoft.EntityFrameworkCore.InMemory package.
dotnet add package Microsoft.EntityFrameworkCore.InMemory
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
public class Program
{
public static async Task Main()
{
var options = new DbContextOptionsBuilder<InventoryDbContext>()
.UseInMemoryDatabase("WarehouseDb")
.Options;
// --- 1. Initial Data Setup ---
using (var context = new InventoryDbContext(options))
{
context.ProductStocks.Add(new ProductStock
{
Id = 101,
ProductName = "Gaming Mouse G-500",
Quantity = 50
});
await context.SaveChangesAsync();
}
// --- 2. Simulating a Conflict ---
Console.WriteLine("--- Starting stock update process ---");
// User A retrieves stock data (currently displayed on screen)
using (var contextA = new InventoryDbContext(options))
using (var contextB = new InventoryDbContext(options))
{
var stockA = await contextA.ProductStocks.FindAsync(101);
Console.WriteLine($"[User A] Retrieved: {stockA.ProductName} / Stock: {stockA.Quantity}");
// User B interrupts and updates the stock first
var stockB = await contextB.ProductStocks.FindAsync(101);
stockB.Quantity = 40; // Shipping 10 units
await contextB.SaveChangesAsync();
Console.WriteLine($"[User B] Update complete: Changed stock to 40.");
// User A tries to update based on old information
stockA.Quantity = 60; // User A thinks it's 50 and adds 10
Console.WriteLine($"[User A] Attempting save (Updating stock to 60)...");
try
{
// This call will fail because User A's RowVersion is outdated
await contextA.SaveChangesAsync();
Console.WriteLine("[User A] Save successful");
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine("--------------------------------------------------");
Console.WriteLine("[Error] Save failed: Data was updated by another user.");
Console.WriteLine("--------------------------------------------------");
// Checking conflict details
var entry = ex.Entries[0];
var databaseValues = await entry.GetDatabaseValuesAsync();
var dbQty = (int)databaseValues["Quantity"];
Console.WriteLine($"Your input value: {stockA.Quantity}");
Console.WriteLine($"Database current value: {dbQty}");
Console.WriteLine("Note: Please reload the screen and check the latest stock count.");
}
}
}
}
// Entity definition
public class ProductStock
{
public int Id { get; set; }
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
// Token for concurrency control
// In SQL Server, this maps to rowversion and changes automatically on update
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
// DbContext definition
public class InventoryDbContext : DbContext
{
public InventoryDbContext(DbContextOptions<InventoryDbContext> options)
: base(options) { }
public DbSet<ProductStock> ProductStocks => Set<ProductStock>();
}
Customization Points
- Alternative to Timestamp: If your database does not have a specific type for versioning, you can add the
[ConcurrencyCheck]attribute directly to theQuantityproperty. This treats any change to that specific value as a conflict. - Automatic Resolution Logic: For simple arithmetic like
Quantity += 10, you can write a retry loop. UseReloadAsyncto get the latest value, recalculate, and try saving again. - Hidden Fields:
RowVersionis binary data and is not user-friendly. When mapping to a DTO (Data Transfer Object), either exclude it or store it as a Base64 string in a hidden field.
Important Notes
- Stateless Web Apps: In Web APIs, you must send the
RowVersionto the client. The client must send it back during the update request. Set theRowVersionon the entity before callingSaveChangesAsyncto ensure the check works. - Risk of Overwriting: If you choose to ignore conflicts and use a “Client Wins” strategy, you will discard other users’ updates. Design this carefully based on your business requirements.
- In-Memory Limitations: Some testing databases might not perfectly emulate the automatic update of
[Timestamp]. In such cases, you may need to overrideSaveChangesand update the byte array manually.
Advanced Application
Retry Logic During Conflicts (For addition logic)
This pattern is useful when you want to handle “changes” rather than “overwrites.” It re-fetches the latest value and performs the calculation again.
int retryCount = 0;
while (retryCount < 3)
{
try
{
await context.SaveChangesAsync();
break; // Exit loop if successful
}
catch (DbUpdateConcurrencyException ex)
{
// Fetch the latest values from the database
var entry = ex.Entries.Single();
await entry.ReloadAsync();
// Re-apply business logic (e.g., add 10 to current stock)
var stock = (ProductStock)entry.Entity;
stock.Quantity += 10;
retryCount++;
}
}
Conclusion
Decide whether to show the latest value to the user or retry automatically based on your business rules. Using the [Timestamp] attribute allows you to set up strict concurrency control at the database level with very little code. This pattern is essential for data where integrity is the highest priority, such as product inventory or financial transactions.
