“Two users edit the same record at the same time. How do you stop one from overwriting the other?” This is the lost update problem, and interviewers use it to test whether you understand concurrency beyond synchronized. In a Spring Boot service running several instances, Java locks are useless; the database is the only shared place. The expected answer is JPA optimistic locking with @Version, followed by a good explanation of what to do when a conflict happens.
Before you start
You should know how a JPA entity is loaded and saved through a Spring Data repository, what a transaction is, and how dirty checking issues an UPDATE at flush time. Examples target Spring Boot 3.x with Hibernate 6.
The short answer
Add a @Version field (usually long) to the entity. Hibernate includes the version in every UPDATE ... WHERE id = ? AND version = ? and increments it. If another transaction changed the row first, the update matches zero rows and Hibernate throws OptimisticLockException, which Spring translates into ObjectOptimisticLockingFailureException. No database lock is held between read and write, so it suits low-contention data. For user edits, return 409 Conflict and let the user reload; for idempotent background updates, retry the whole transaction. For an API, the client must send back the version it read, or the protection only covers the milliseconds inside one request.
How it works
@Entity
public class Product {
@Id @GeneratedValue private Long id;
private String name;
private BigDecimal price;
private int stock;
@Version
private long version;
// getters and setters
}Load product 7 (version 3), change the price, commit. Hibernate issues:
update product set name=?, price=?, stock=?, version=4 where id=7 and version=3The JDBC driver reports the update count. If it is 1, the write succeeded and the entity now has version 4. If it is 0, someone else already moved the row to version 4, and Hibernate throws. The check is atomic because the database evaluates the WHERE clause and the write together.
Here is the timeline that optimistic locking prevents:
T1: read product 7 (price 10, version 3)
T2: read product 7 (price 10, version 3)
T2: set price 12 -> update ... where version=3 -> 1 row, version 4
T1: set price 11 -> update ... where version=3 -> 0 rows -> OptimisticLockExceptionWithout @Version, T1’s update would silently overwrite T2’s price.
Step-by-step walkthrough
Step 1: Add the version column
Add @Version private long version; and a database migration with version bigint not null default 0. Do not write setters that let application code change it. Hibernate owns it.
Step 2: Carry the version through the API
A web edit spans two requests: GET to show the form, PUT to save. If the PUT handler loads a fresh entity and applies changes, it gets the current version, so the check passes even when the user edited stale data. The client must send the version it saw:
public record UpdatePriceRequest(@NotNull BigDecimal price, long version) { }
@Transactional
public Product updatePrice(long id, UpdatePriceRequest req) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
if (product.getVersion() != req.version()) {
throw new ObjectOptimisticLockingFailureException(Product.class, id);
}
product.setPrice(req.price());
return product; // flushed at commit with "where version = ?"
}The explicit comparison catches staleness across requests; the WHERE version = ? at commit catches races within the request window. HTTP’s ETag and If-Match headers are the standard way to carry the version: return ETag: "3" on GET, require If-Match: "3" on PUT, and answer 412 Precondition Failed on mismatch.
Step 3: Map the conflict to a response
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
ProblemDetail conflict(ObjectOptimisticLockingFailureException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"This record was changed by someone else. Reload and try again.");
pd.setType(URI.create("https://api.example.com/problems/edit-conflict"));
return pd;
}Step 4: Retry only where retrying is correct
A background job that decrements stock can safely re-read and retry, because the decision (“is stock > 0?”) is recomputed from fresh data. The retry must wrap the whole transaction, not live inside it:
@Service
class StockService {
private final StockTx tx; // separate bean so @Transactional applies per attempt
StockService(StockTx tx) { this.tx = tx; }
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true))
public void reserve(long productId, int qty) {
tx.reserve(productId, qty);
}
}@Retryable comes from Spring Retry (enable with @EnableRetry). Retrying inside the failed transaction does not work: the persistence context holds the stale entity, and the transaction is already marked rollback-only.
Worked scenario
A back-office tool lets two support agents edit a customer’s shipping address and credit limit. Agent A opens the customer at 10:00 and changes the address. Agent B opens it at 10:01 and raises the credit limit, saving at 10:02. Agent A saves at 10:05. The PUT endpoint maps the whole form onto a freshly loaded entity, so A’s save writes the old credit limit back. B’s change is gone and nobody is told.
The entity already had @Version, which is what confused the team. But A’s request loaded the entity at 10:05, after B’s commit, so the version matched. The fix sends the version with the form, compares it as in Step 2, and returns 409 with a message. The UI then shows A the current values and asks them to reapply the address change. For field-level merging, the team could compare which fields A actually changed, but the simpler 409 resolves the data loss.
Common mistake
- “
@Versionalone protects the API.” Only if the client’s version reaches the update. Loading fresh data in the PUT defeats it. - “Optimistic locking locks the row.” It holds no lock; it detects conflicts at write time.
- Retrying user edits automatically. The retry reapplies the user’s change over someone else’s without their knowledge, which is the lost update again.
- Catching the exception inside the
@Transactionalmethod. The exception surfaces at flush, often at commit after your method returns, and the transaction is already doomed. - Using optimistic locking for hot rows. A counter updated by hundreds of requests per second will conflict constantly; use an atomic SQL update (
set stock = stock - ? where stock >= ?) or pessimistic locking instead.
Verify the behavior
Simulate two transactions with TransactionTemplate:
@SpringBootTest
class ProductVersionTest {
@Autowired ProductRepository products;
@Autowired PlatformTransactionManager txManager;
@Test
void staleUpdateFails() {
TransactionTemplate tx = new TransactionTemplate(txManager);
long id = tx.execute(s -> products.save(new Product("Lamp", new BigDecimal("10"))).getId());
Product stale = tx.execute(s -> products.findById(id).orElseThrow()); // version 0, detached
tx.executeWithoutResult(s -> products.findById(id).orElseThrow().setPrice(new BigDecimal("12")));
stale.setPrice(new BigDecimal("11"));
assertThatThrownBy(() -> tx.executeWithoutResult(s -> products.save(stale)))
.isInstanceOf(ObjectOptimisticLockingFailureException.class);
}
}save on the detached entity calls merge, which sees version 0 while the row is at version 1 and fails. The test does not use @Transactional on the class because it needs several independent commits.
Follow-up questions
When would you choose pessimistic locking? When conflicts are frequent or the cost of redoing work is high. @Lock(LockModeType.PESSIMISTIC_WRITE) on a repository method issues SELECT ... FOR UPDATE and holds a row lock until commit.
Can you force a version increment without changing fields? Yes, LockModeType.OPTIMISTIC_FORCE_INCREMENT, useful when changing children should conflict with edits to the parent aggregate.
What types can @Version use? Numeric types (int, long, Integer, Long, short) and timestamps. Numbers are preferred because timestamps can collide.
Does @Version work with bulk JPQL updates? No. update Product p set ... bypasses entity versioning unless you increment the version in the statement yourself.
Interview exercise
Flash sale: 1,000 users try to buy the last 50 units of a product in a few seconds. The product entity has @Version. What happens, and what would you change?
Answer and reasoning
Most purchase transactions read the same version, so only one per version succeeds and the rest throw conflicts. Retries pile up, latency climbs, and many users fail even while stock remains. Optimistic locking is the wrong tool for a hot row. Use a single atomic statement, update product set stock = stock - 1 where id = ? and stock > 0, and check that the update count is 1. It serializes briefly inside the database with no lost updates and no retries. Alternatively, reserve stock through a queue or pre-split inventory into buckets. Keep @Version for ordinary product edits by staff, where conflicts are rare. The reasoning: choose the concurrency control by contention level.