java 135 lines · 3 tabs

Optimistic Locking with JPA @Version and Retry on Concurrent Updates

Shared by codesnips Jul 2026
3 tabs
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.Objects;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private BigDecimal balance;

    @Version
    @Column(nullable = false)
    private long version;

    protected Account() {
    }

    public Account(BigDecimal balance) {
        this.balance = balance;
    }

    public void withdraw(BigDecimal amount) {
        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException(id, balance, amount);
        }
        this.balance = balance.subtract(amount);
    }

    public Long getId() {
        return id;
    }

    public BigDecimal getBalance() {
        return balance;
    }

    public long getVersion() {
        return version;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Account)) return false;
        return id != null && id.equals(((Account) o).id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}
3 files · java Explain with highlit

Optimistic locking assumes conflicts are rare and lets concurrent transactions read the same row freely, only detecting a clash at write time. JPA implements this with a @Version column: Hibernate reads the version when an entity is loaded and, on flush, issues an UPDATE ... WHERE id = ? AND version = ?. If another transaction already bumped the version, the update affects zero rows and Hibernate throws an OptimisticLockException (wrapped by Spring as ObjectOptimisticLockingFailureException). This avoids the throughput cost of pessimistic row locks while still guaranteeing lost updates cannot silently occur.

In Account entity, the version field is annotated with @Version so Hibernate manages it automatically — application code never sets it. The withdraw method contains the domain invariant (sufficient balance), while the version guarantees that the balance the caller reasoned about is still current when the write lands. The equals/hashCode pair is deliberately based on the identifier only, which is the safe choice for JPA entities.

AccountService marks withdraw as @Transactional so the load, mutate, and flush all happen in one unit of work. findById reads the current version, withdraw mutates in place, and the flush at commit performs the version-checked update. Note that the conflict does not surface at the save call but at flush/commit, which is why the exception often escapes the method body — a common source of confusion.

AccountController closes the loop by turning the low-level failure into a meaningful API response. It uses Spring Retry's @Retryable to transparently re-run the whole transactional operation on ObjectOptimisticLockingFailureException, because a retry re-reads the fresh version and usually succeeds. A bounded maxAttempts with backoff prevents a retry storm, and @Recover maps exhausted retries to HTTP 409 Conflict, signalling the client to resubmit.

The trade-off is that optimistic locking shifts conflict handling to write time, so hot rows with heavy contention may retry frequently — in that case pessimistic locking or reduced contention is preferable. For typical low-contention workloads, this pattern gives correctness with minimal locking overhead, and the retry loop makes conflicts invisible to well-behaved clients.


Related snips

Share this code

Here's the card — post it anywhere.

Optimistic Locking with JPA @Version and Retry on Concurrent Updates — share card
Link copied