Spring Boot
Optimistic Locking
Optimistic locking prevents lost updates in concurrent environments without holding database locks. A version field on the entity is incremented on every UPDATE; if two transactions read the same version and both try to update, the second commit fails with OptimisticLockException. This entry covers @Version setup, exception handling, retry logic, and REST API patterns.
@Version Setup
Add a single @Version field to any entity that needs concurrent update protection. Hibernate manages the field automatically — incrementing it on every UPDATE and including it in the WHERE clause. No application code is needed to increment the version; attempting to modify it manually will cause issues.
Java
// ── Integer version ───────────────────────────────────────────────────
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Version
@Column(nullable = false)
private Integer version; // starts at 0, incremented on each UPDATE
}
// ── Long version (preferred for high-volume entities) ─────────────────
@Entity
@Table(name = "orders")
@Getter @Setter @NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal total;
@Version
@Column(nullable = false)
private Long version;
}
// ── Timestamp version — uses DB timestamp instead of counter ──────────
@Entity
@Table(name = "inventory_items")
@Getter @Setter @NoArgsConstructor
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private int stockLevel;
@Version
@Column(nullable = false)
private Instant lastModified; // DB sets this on each UPDATE
}
// ── What Hibernate generates ───────────────────────────────────────────
// UPDATE products
// SET name = ?, price = ?, version = 2 ← incremented
// WHERE id = 42 AND version = 1 ← must match current value
//
// If 0 rows updated → OptimisticLockException (another tx updated first)Handling OptimisticLockException
When two transactions conflict, the second commit throws OptimisticLockException (JPA) or StaleObjectStateException (Hibernate). Wrap it in a domain exception in the service layer and surface a 409 Conflict to the API client. Always include the current version in the response so the client can re-fetch and retry.
Java
// ── Service — detect and wrap the exception ──────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepo;
@Transactional
public ProductResponse update(Long id,
UpdateProductRequest request,
Integer expectedVersion) {
Product product = productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// Optional: validate version before writing
if (!product.getVersion().equals(expectedVersion)) {
throw new ConflictException(
"Product has been modified since you last loaded it. " +
"Please reload and retry.");
}
product.setName(request.name());
product.setPrice(request.price());
try {
return ProductResponse.from(productRepo.saveAndFlush(product));
} catch (OptimisticLockException |
StaleObjectStateException ex) {
log.warn("Optimistic lock conflict on product {}", id);
throw new ConflictException(
"Product was updated by another user. " +
"Please reload and retry.");
}
}
}
// ── Global handler maps the exception to 409 ──────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler({
OptimisticLockException.class,
StaleObjectStateException.class,
ObjectOptimisticLockingFailureException.class
})
public ResponseEntity<ErrorResponse> handleOptimisticLock(
Exception ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.of(
409, "Conflict",
"The record was modified concurrently. " +
"Please reload and retry.",
req.getRequestURI()));
}
}
// ── Response DTO exposes version to the client ─────────────────────────
public record ProductResponse(
Long id,
String name,
BigDecimal price,
Integer version // ← client must send this back on update
) {
public static ProductResponse from(Product p) {
return new ProductResponse(
p.getId(), p.getName(), p.getPrice(), p.getVersion());
}
}Version in REST API Requests
The client must send the version it last read when submitting an update. The version can arrive as a request body field, a query parameter, or the If-Match HTTP header. The If-Match pattern is the most RESTful — it uses the ETag mechanism defined in RFC 7232.
Java
// ── Option 1: Version in request body ────────────────────────────────
public record UpdateProductRequest(
@NotBlank String name,
@NotNull BigDecimal price,
@NotNull Integer version // client sends back the version it read
) {}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> update(
@PathVariable Long id,
@RequestBody @Valid UpdateProductRequest request) {
return ResponseEntity.ok(
productService.update(id, request, request.version()));
}
// ── Option 2: Version as query parameter ─────────────────────────────
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> update(
@PathVariable Long id,
@RequestParam Integer version,
@RequestBody @Valid UpdateProductRequest request) {
return ResponseEntity.ok(
productService.update(id, request, version));
}
// ── Option 3: ETag / If-Match (most RESTful) ──────────────────────────
// GET /products/42 → ETag: "3" (version = 3)
// PUT /products/42 → If-Match: "3"
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
ProductResponse product = productService.findById(id);
return ResponseEntity.ok()
.eTag(String.valueOf(product.version())) // ETag = version
.body(product);
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateWithEtag(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH,
required = false) String ifMatch,
@RequestBody @Valid UpdateProductRequest request) {
if (ifMatch == null) {
return ResponseEntity.status(HttpStatus.PRECONDITION_REQUIRED)
.build(); // 428 — version required
}
// Strip quotes: "3" → 3
Integer version = Integer.parseInt(
ifMatch.replace(""", ""));
ProductResponse updated = productService.update(id, request, version);
return ResponseEntity.ok()
.eTag(String.valueOf(updated.version()))
.body(updated);
}Retry on Conflict
For non-interactive operations — background jobs, batch processors, event handlers — retry the operation automatically when an optimistic lock conflict occurs. Spring Retry provides @Retryable for declarative retry with configurable backoff. Limit retries and always log conflicts so they are visible in production.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
// ── Enable retry ───────────────────────────────────────────────────────
@SpringBootApplication
@EnableRetry
public class Application { ... }
// ── Service with declarative retry ────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class InventoryService {
private final InventoryRepository inventoryRepo;
// Retry up to 3 times with 100ms exponential backoff
@Retryable(
retryFor = { OptimisticLockException.class,
ObjectOptimisticLockingFailureException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void decrementStock(Long productId, int quantity) {
InventoryItem item = inventoryRepo.findByProductId(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
if (item.getStockLevel() < quantity) {
throw new InsufficientStockException(
productId, quantity, item.getStockLevel());
}
item.setStockLevel(item.getStockLevel() - quantity);
inventoryRepo.save(item);
log.debug("Decremented stock for product {} by {}",
productId, quantity);
}
@Recover
public void recoverDecrementStock(
OptimisticLockException ex,
Long productId, int quantity) {
log.error("Failed to decrement stock for product {} " +
"after 3 retries", productId, ex);
throw new ServiceUnavailableException(
"Could not update inventory due to high concurrency. " +
"Please retry.");
}
}
// ── Programmatic retry with TransactionTemplate ────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate txTemplate;
private final OrderRepository orderRepo;
public void updateStatus(Long orderId, OrderStatus newStatus) {
int attempts = 0;
while (attempts < 3) {
try {
txTemplate.execute(status -> {
Order order = orderRepo.findById(orderId)
.orElseThrow();
order.setStatus(newStatus);
orderRepo.save(order);
return null;
});
return; // success
} catch (ObjectOptimisticLockingFailureException ex) {
attempts++;
if (attempts == 3) throw ex;
}
}
}
}Optimistic Locking with DTOs and Detached Entities
In a typical REST flow the entity is loaded in one transaction, converted to a DTO, sent to the client, and then updated in a second transaction with a new request. The version field bridges these two transactions — the client sends the version it received, and Hibernate uses it in the UPDATE WHERE clause.
Java
// ── Full REST flow with version ───────────────────────────────────────
// 1. Client GETs the product — receives current version
// GET /api/v1/products/42
// Response: { "id": 42, "name": "Widget", "price": 9.99, "version": 3 }
// 2. Client edits locally (no DB interaction)
// 3. Client PUTs with the version it received
// PUT /api/v1/products/42
// Body: { "name": "Super Widget", "price": 12.99, "version": 3 }
// ── Service — reattach by loading, then apply changes ─────────────────
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepo;
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// If another user updated between GET and this PUT,
// product.version will be 4, request.version() is 3 →
// Hibernate's WHERE id=42 AND version=3 matches 0 rows →
// OptimisticLockException thrown automatically.
product.setName(request.name());
product.setPrice(request.price());
// No need to set version — Hibernate reads it from the entity
// and checks it automatically on flush.
return ProductResponse.from(productRepo.save(product));
}
}
// ── Detached entity update — merge path ───────────────────────────────
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepo;
private final EntityManager em;
@Transactional
public ArticleResponse updateDetached(ArticleDto dto) {
// Reconstruct a detached entity from the DTO (including version)
Article detached = new Article();
detached.setId(dto.id());
detached.setTitle(dto.title());
detached.setBody(dto.body());
detached.setVersion(dto.version()); // ← version from client
// merge() reattaches and Hibernate checks the version on flush
Article merged = em.merge(detached);
return ArticleResponse.from(merged);
}
}