From Spring Boot to EF Core: Understanding Concurrency a Little Differently
Coming from Spring Boot to EF Core, I expected version-based concurrency control to work more or less the same way. The goal was familiar: prevent one update from silently overwriting another.
But while working with SQL Server's rowversion in EF Core, I noticed a small difference that made me rethink what a version column actually represents.
What I Was Used to in Spring Boot
When working with JPA/Hibernate, we can use @Version to enable optimistic locking. For example:
@Entity
public class Product {
@Id private Long id;
private String name;
@Version private Long version;
}
Hibernate manages the version field. When an entity is updated, its version is incremented, and the version is checked during the update.
Conceptually, it might look like this:
| Operation | Version |
|---|---|
| Product created | 0 |
| Product updated | 1 |
| Product updated again | 2 |
The version belongs to that entity's row. Updating a different product doesn't increment this product's version. That was the mental model I had in mind.
Then I Encountered .IsRowVersion() in EF Core
In EF Core, SQL Server can manage the concurrency token for us:
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();
The corresponding property is typically a byte[]:
public class Product {
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
At first glance, it serves the same purpose as JPA's @Version. But the way SQL Server generates the value is different.
SQL Server's rowversion is an automatically generated 8-byte binary value based on a database-wide counter. Whenever a row containing a rowversion column is inserted or updated, SQL Server assigns it a new value.
Here's the part I found interesting: the counter isn't maintained separately for each row.
Why Does That Matter?
Imagine a Products table containing three products.
| Operation | Product | Rowversion (illustrative) |
|---|---|---|
| Insert | Laptop | 0x0000000000000001 |
| Insert | Keyboard | 0x0000000000000002 |
| Update | Keyboard | 0x0000000000000003 |
| Insert | Mouse | 0x0000000000000004 |
| Update | Laptop | 0x0000000000000005 |
These values are illustrative, but they demonstrate the idea. The laptop was inserted once and updated once. Yet its final rowversion is 0x0000000000000005, not 0x0000000000000002.
Why? Because other rows were inserted or updated in between. Those operations advanced the database-wide counter. When the laptop was updated, SQL Server assigned it the next applicable value.
Importantly, updating the keyboard did not change the laptop's stored rowversion. The laptop received a new value only when the laptop itself was updated.
How Does This Help Prevent Lost Updates?
Suppose two users load the same product. Both read the current rowversion, say 0x0000000000000005. The first user updates the product. SQL Server assigns a new rowversion.
When the second user tries to save their changes using the original token, EF Core includes that original value in the update condition. Conceptually, the SQL looks like this:
UPDATE Products SET Name = @newName WHERE Id = @id AND RowVersion = @originalRowVersion;
If the first update has already changed the rowversion, the second update matches zero rows. EF Core then throws a DbUpdateConcurrencyException, allowing the application to handle the conflict instead of silently overwriting the first user's changes.
That's optimistic concurrency control in action.
The Takeaway
Both JPA/Hibernate's @Version and EF Core's .IsRowVersion() help protect against conflicting updates. However, their version generation mechanisms are different.
- JPA/Hibernate
@Version: typically uses an entity level numeric version managed by the ORM. - SQL Server rowversion: uses a database wide counter to generate binary values for affected rows.
Understanding this difference helped me separate two ideas that I had previously treated as the same thing, a record's version and a database generated concurrency token.
It's a small implementation detail, but these are the kinds of details that make working across frameworks interesting. Sometimes, understanding how something works under the hood is more valuable than simply knowing which annotation or method to use.
Comments
No comments yet. Start the discussion.