Two users edit the same record; the second save must not silently erase the first. Both JPA and Storm solve this with a version column. The difference is where the check runs and what you hold in your hands when it fails.
An owner record is open in two browser tabs. Both edit, both save. Without a guard, the last write wins and the first edit vanishes without a trace. Optimistic locking detects the collision and turns it into an error you can handle.
JPA solves this with @Version on a managed entity. The mechanics work, but notice where everything happens:
@Entity class Owner( @Id @GeneratedValue var id: Int? = null, var firstName: String = "", var lastName: String = "", @Version var version: Int = 0, ) @Transactional fun rename(id: Int, lastName: String) { val owner = ownerRepository.findById(id).orElseThrow() owner.lastName = lastName // no save call: the UPDATE happens at flush, the version check // at commit, and a conflict surfaces far from this line }
The write is implicit: mutating the managed entity schedules an UPDATE for whenever the persistence context flushes. The version check runs at that flush, so the failure appears at commit time, at the end of the transaction, often wrapped by Spring into ObjectOptimisticLockingFailureException somewhere above the code that made the change. And because the check belongs to the session, handling a conflict across requests means detached entities and merge(), which is its own chapter of subtleties.
Storm has no flush and no managed state, so the version check happens in the only place left: the UPDATE statement itself, on the line where you call it:
data class Owner( @PK val id: Int = 0, val firstName: String, val lastName: String, @Version val version: Int = 0, ) : Entity<Int> val owners = orm.entity<Owner, _>() val owner = owners.getById(1) val updated = owners.updateAndFetch(owner.copy(lastName = "Smith")) // the UPDATE ran here, on this line, and updated.version is incremented
-- updateAndFetch(): the version is part of the WHERE clause UPDATE owner SET first_name = ?, last_name = ?, version = ? WHERE id = ? AND version = ? -- zero rows matched means another writer won: OptimisticLockException, thrown here
The update is explicit, the exception is immediate, and there is no session for the failure to hide behind. copy() expresses the change; updateAndFetch() returns the new state with the incremented version, ready for the next edit.
Because entities are immutable values, a conflict leaves you with something unusually useful: the stale copy you tried to write and the current copy from the database, both plain data classes that you can diff field by field:
try { owners.update(stale.copy(lastName = "Smith")) } catch (exception: OptimisticLockException) { val current = owners.getById(stale.id) // stale and current are plain values: diff them, merge, retry, // or surface the conflict to the user }
There is nothing to detach, reattach, or merge. Retry logic is ordinary code operating on ordinary values.
If the schema tracks a last-modified timestamp anyway, it can serve as the version:
// A timestamp works as the version too data class Visit( @PK val id: Int = 0, val visitDate: LocalDate, val description: String? = null, @Version val timestamp: Instant?, ) : Entity<Int>
| JPA with Hibernate | Storm | |
|---|---|---|
| When the check runs | At flush or commit, wherever that happens to be | On the update call, on that line |
| Failure surfaces as | ObjectOptimisticLockingFailureException at commit | OptimisticLockException at the call site |
| State model | Managed, mutable entities; writes are implicit | Immutable values; every write is a visible call |
| Conflict handling | Detach, reload, merge | Stale and current copies coexist as plain values |
The reference documentation covers the mechanics in depth: