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);
}
}
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountService {
private final AccountRepository accounts;
public AccountService(AccountRepository accounts) {
this.accounts = accounts;
}
@Transactional
public Account withdraw(Long accountId, BigDecimal amount) {
Account account = accounts.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException(accountId));
// The @Version check runs at flush/commit, not here.
account.withdraw(amount);
return accounts.save(account);
}
@Transactional(readOnly = true)
public Account get(Long accountId) {
return accounts.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException(accountId));
}
}
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
@RestController
@RequestMapping("/accounts")
public class AccountController {
private final AccountService service;
public AccountController(AccountService service) {
this.service = service;
}
@PostMapping("/{id}/withdraw")
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 40, multiplier = 2.0, random = true))
public ResponseEntity<Account> withdraw(@PathVariable Long id,
@RequestBody WithdrawRequest request) {
Account updated = service.withdraw(id, request.amount());
return ResponseEntity.ok(updated);
}
@Recover
public ResponseEntity<Map<String, Object>> recover(OptimisticLockingFailureException ex,
Long id) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of(
"error", "concurrent_modification",
"accountId", id,
"message", "Please retry the request."));
}
public record WithdrawRequest(BigDecimal amount) {
}
}
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
Share this code
Here's the card — post it anywhere.