skip to content

A user opens an edit form on a record, spends several minutes changing it, and submits. How do you use a JPA @Version field to detect that somebody else changed that row in the meantime, and what common implementation mistake silently defeats the check?

level: seniorimportance: should knowfreq 45%

answer

  1. Version travels to the client and back
  2. merge(detached) compares versions at merge time
  3. Fresh find + copy fields = check silently gone
  4. Merge copies ALL state — partial forms overwrite
  5. Conflict -> tell the user, don't auto-retry

basics

~20 s

Send the loaded version out with the form, get it back on submit, put it on the detached entity, and merge. Hibernate compares the supplied version with the database row and rejects a stale one. The classic mistake is re-reading the entity server-side and copying only the form fields onto it — the managed copy carries a fresh version, so the check always passes.

solid answer

~60 s

The edit spans two requests, so no persistence context is alive between them. The version has to travel: 1. On load, include the version in the DTO or as a hidden form field (or an HTTP `ETag`). 2. On submit, bind it back onto a **detached** entity instance together with the edited fields. 3. `merge(detached)` — Hibernate loads the current row, compares versions, and if the incoming one is older it raises `OptimisticLockException` immediately; otherwise the flush emits `update ... where id=? and version=?`, which is the second line of defence against a change landing in between. The mistake that silently defeats it: loading the entity fresh in the submit handler and copying only the user's fields onto that managed instance. The managed copy holds the *current* version, so the predicate always matches and the other user's edit is overwritten. The version must come from the client, not from a fresh read. Other traps: mappers that skip the version field, and treating the client's version as trusted for anything except conflict detection.

code

java · 6 lines
java
Product p = new Product();
p.setId(form.id());
p.setVersion(form.version());   // version the user actually saw
p.setName(form.name());
p.setPrice(form.price());
Product managed = em.merge(p);  // OptimisticLockException if the row moved on

go deeper

for a junior

Know that the version must be sent to the client and returned with the submission, and that ignoring it means the last save silently wins.

for a middle

Show the detached-merge flow and identify the load-then-copy anti-pattern as the thing that silently removes protection.

for a senior

Add when merge detects the conflict, the partial-form overwrite hazard, why retry is wrong for human conflicts, and the regression test that distinguishes protected from unprotected code.

for a principal

Treat the version as part of the external contract — its type, its transport (ETag/If-Match), the API's conflict semantics — and set a house pattern so individual handlers cannot silently opt out.

## Why the normal mechanism is not enough Optimistic locking inside a single transaction protects you between load and flush — microseconds to seconds. A user editing a form is a **conversation**: minutes elapse, and there is no open persistence context, no transaction, and no snapshot on the server. Nothing in the ORM spans that gap by itself. What spans it is the version *value*, if you carry it. ## The pattern **Request 1 — render.** Load the record and include the version in whatever leaves the server: ```java record ProductForm(Long id, String name, BigDecimal price, long version) {} ``` Or as an HTTP `ETag`, which the client returns in `If-Match` — the same idea expressed at the protocol level. **Request 2 — submit.** Build a **detached** entity from the submitted data *including the version*, and merge it: ```java Product p = new Product(); p.setId(form.id()); p.setVersion(form.version()); // the version the user actually saw p.setName(form.name()); p.setPrice(form.price()); em.merge(p); ``` Hibernate's `merge` loads the current row into the persistence context and compares the detached instance's version with it. If the detached version is older, it raises `OptimisticLockException` right there — you do not even wait for the flush. If they match, the state is copied onto the managed entity and the flush emits the usual versioned update, which catches any change that occurred in the milliseconds since the merge read. ## The mistake that silently defeats it By far the most common implementation: ```java Product managed = em.find(Product.class, form.id()); // fresh row, current version managed.setName(form.name()); managed.setPrice(form.price()); // version never compared to what the user saw ``` This compiles, passes tests, and is completely unprotected. The managed instance was loaded *now*, so its version is whatever the other user's edit left behind. The flush emits `where version = <current>`, matches one row, and the other user's change is gone. No exception, no log line — the exact lost update the mechanism exists to prevent. The rule: **the version must come from the client's copy of the data**, not from a fresh read. A safe variant of the load-then-copy style is to load the entity and compare explicitly before mutating — `if (managed.getVersion() != form.version()) throw conflict;` — or to call `em.lock(managed, LockModeType.OPTIMISTIC)` after checking, but the merge-a-detached-instance form is less error-prone because the comparison is not something a future edit can forget. ## Adjacent traps - **Mappers that skip the version.** A bean mapper configured to ignore "technical" fields, or a DTO that simply omits it, silently reduces the flow to the unprotected variant. Assert it in a test. - **Client trust.** The version is not a security control. A malicious client can send any number; the worst outcome is overwriting a concurrent edit, not privilege escalation. Never use it for authorisation, and never let a missing version mean "skip the check" — treat absent as a conflict. - **`merge` returns a new instance.** Continue working with the returned managed object; the detached argument stays detached. - **Partial forms.** Merging a detached instance copies **all** mapped state, so fields the form did not include get overwritten with nulls or defaults. Either carry the full state or use the explicit compare-then-mutate style. - **The version leaving the system.** Once clients hold it, it is part of your contract; changing its type or semantics later is a client-visible change. A plain counter travels better than a formatted timestamp. ## What to do on conflict Automatic retry is usually wrong here: the operation is "set these fields to what a human typed against the old values", and redoing it against fresh data would overwrite the other person by definition. Instead return a conflict (`409` over HTTP), reload, and show the user what changed — a diff, or at minimum "this record was modified while you were editing". For genuinely mergeable data you can attempt a field-level merge and only escalate on overlapping fields. ## Testing it The test that matters: load a record, capture its version, update the row through a second path, then submit the first version and assert a conflict. Without that test, the unprotected variant is indistinguishable from the protected one in every other check you run.

  • At what point does merge() detect the conflict — at merge or at flush?
    At merge. Hibernate loads the current row into the persistence context and compares it with the detached instance's version, raising OptimisticLockException immediately if the detached one is older. The versioned UPDATE emitted at flush is a second check that also covers changes occurring between the merge and the commit.
  • Why is automatic retry usually the wrong response to a conflict in this flow?
    Because the operation is "apply the values a human typed against the state they saw". Redoing it against fresh data would overwrite the other person's edit deliberately, which is exactly what the check prevented. The right response is to report the conflict, reload, and let the user reconcile — automatic retry is for recomputable, machine-driven operations.
  • What is the risk of merging a detached instance built from a partial form?
    merge copies the full mapped state of the detached instance onto the managed one, so any field the form did not populate is written as null or default, wiping data the user never intended to touch. Either carry complete state through the conversation or use an explicit compare-then-mutate approach that only assigns the edited fields.

Like quoting the revision you edited when you submit a change: if the base revision no longer matches, the change is refused rather than applied blindly on top of someone else's.

saying these in an interview costs you the question

  • Loading the entity fresh in the submit handler and assuming the version check still applies.
  • Leaving the version out of the DTO or letting a bean mapper ignore it.
  • Treating the client-supplied version as a trusted or security-relevant value.
  • Assuming merge re-attaches the instance you passed in rather than returning a different managed copy.
  • Merging a partially populated detached entity and being surprised that untouched columns are nulled.

context