Implement Optimistic Concurrency in NHibernate 5.x with a Version Column
Map a version column, carry it across web requests, handle StaleObjectStateException with a bounded retry, and verify the UPDATE’s WHERE clause enforces the check.
07 May 2026, 22:44 UTC

Two users edit the same row and both click Save. Without a concurrency token the second write silently overwrites the first – a lost‑update bug. NHibernate’s optimistic locking solves this by checking a version column in the UPDATE’s WHERE clause and throwing StaleObjectStateException when the row has changed.
Prerequisites
NHibernate 5.x, a regular ISession (stateless sessions bypass version checks), and a database you can alter. You need access to your mapping files (XML or Fluent NHibernate) and ability to run an integration test against a real database.
Choose a numeric version
Prefer <version> (numeric or GUID) over <timestamp>. The numeric version is portable across SQL Server, PostgreSQL, MySQL, and Oracle; NHibernate increments it on each flush. Timestamps rely on database‑specific types such as SQL Server ROWVERSION and are less portable.
Map the version property
XML mapping
<class name='Customer' table='Customers'>
<id name='Id' column='Id'>
<generator class='identity'/>
</id>
<version name='Version' column='RowVersion' type='int'/>
<property name='Name'/>
</class>Fluent NHibernate mapping
public class CustomerMap : ClassMap<Customer>{
public CustomerMap(){
Id(x => x.Id);
Version(x => x.Version)
.Column('RowVersion')
.CustomSqlType('BIGINT')
.Not.Nullable();
Map(x => x.Name);
}
}On the entity expose the property with a private or protected setter so application code cannot overwrite it:
public class Customer{
public virtual int Id { get; protected set; }
public virtual long Version { get; protected set; }
public virtual string Name { get; set; }
}Pass the version across a web request
Render the version into a hidden form field (or ETag header). On POST, set it on the detached instance before attaching to the session:
// postedVersion comes from the hidden field
var customer = new Customer { Id = postedId, Name = postedName };
customer.SetVersionForConcurrencyCheck(postedVersion); // internal setter
using (var tx = session.BeginTransaction()){
session.Update(customer);
tx.Commit(); // generates UPDATE … WHERE Id = @id AND RowVersion = @version
}If another request committed in between, the WHERE clause matches zero rows and NHibernate throws StaleObjectStateException.
Handle StaleObjectStateException with a bounded retry
The exception is thrown during flush or commit. Reload the entity, re‑apply the user's changes, and retry – do not swallow the exception.
const int maxAttempts = 3;
for (var attempt = 1; ; attempt++){
using (var session = factory.OpenSession())
using (var tx = session.BeginTransaction()){
try{
var customer = session.Get<Customer>(id); // fresh version
customer.Name = newName; // re‑apply changes
tx.Commit();
return;
}catch (StaleObjectStateException) when (attempt < maxAttempts){
// session is unusable; next iteration creates a fresh session
}
}
}Discard the session after the exception; a session that has thrown during flush is in an undefined state. For edits where a blind retry would lose data, surface the conflict to the user instead.
Detached entities and LockMode.Optimistic
If you hold a detached entity across a long conversation and want a version check without dirty‑checking the whole graph, call:
session.Lock(entity, LockMode.Optimistic);LockMode.OptimisticForceIncrement additionally increments the version even if nothing else changed. Keep the detached instance intact; evicting it loses the version value. Use session.Disconnect()/Reconnect() for long conversations rather than evicting.
What bypasses the check
- Bulk HQL or SQL UPDATE/DELETE. These statements go straight to the database. To emulate optimistic locking include the version in the WHERE clause yourself, e.g.
UPDATE Customer SET Name = :name WHERE Id = :id AND Version = :expectedVersion, and verify the affected row count equals 1. - StatelessSession. No dirty checking, no version checking – use a regular ISession for versioned entities.
- Second‑level cache misconfiguration. For versioned entities configure the cache region with
ReadWriteorTransactionalconcurrency strategy;NonstrictReadWritemay serve stale versions under contention.
Verification steps
- Schema check. Run
SchemaValidatoror inspect the table directly (SHOW CREATE TABLEon MySQL,sp_helpon SQL Server). Confirm the version column exists, is non‑nullable, and has the expected type (BIGINT/ROWVERSION). - SQL trace. Enable NHibernate SQL logging (log4net or Serilog at DEBUG for the
NHibernate.SQLlogger) and perform one update. The UPDATE statement must containWHERE Id = @p0 AND RowVersion = @p1and there should be no extra SELECT before the UPDATE for a normally attached entity. - Concurrency test. In an integration test against a real database load the same entity in two sessions, modify both, commit the first, and assert the second commit throws
StaleObjectStateException. Then exercise your retry path and assert it succeeds. For a stronger signal runParallel.ForEachwith 20‑50 workers updating the same row and confirm no update is lost and the final version equals the number of successful commits.
Limitations
Composite‑id entities can carry a version, but timestamp versions are not supported on composite keys in some dialects – test your target database. Optimistic locking detects conflicts only at flush time, so under very high contention on a single row a pessimistic lock (LockMode.Upgrade) may yield fewer retries. Because detection happens at commit, design the UI so a conflict is explainable to the user, not just logged.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.