Optimistic Locking on Inventory Updates with @Version and a Retrying Conflict Handler in Spring Boot
package com.shop.inventory;
import jakarta.persistence.*;
@Entity
@Table(name = "inventory_item")
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String sku;
@Column(nullable = false)
private int available;
@Version
private long version;
protected InventoryItem() {
}
public void decrement(int qty) {
if (qty <= 0) {
throw new IllegalArgumentException("qty must be positive");
}
if (available - qty < 0) {
throw new IllegalStateException("insufficient stock for sku " + sku);
}
this.available -= qty;
}
public void increment(int qty) {
if (qty <= 0) {
throw new IllegalArgumentException("qty must be positive");
}
this.available += qty;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public int getAvailable() {
return available;
}
public long getVersion() {
return version;
}
}
package com.shop.inventory;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
private final InventoryItemRepository repository;
public InventoryService(InventoryItemRepository repository) {
this.repository = repository;
}
@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 4,
backoff = @Backoff(delay = 25, multiplier = 2.0, random = true)
)
@Transactional
public InventoryItem adjustStock(String sku, int delta) {
InventoryItem item = repository.findBySku(sku)
.orElseThrow(() -> new UnknownSkuException(sku));
if (delta < 0) {
item.decrement(-delta);
} else {
item.increment(delta);
}
// Commit issues UPDATE ... WHERE id = ? AND version = ?
return repository.save(item);
}
@Recover
public InventoryItem recoverFromConflict(ObjectOptimisticLockingFailureException ex,
String sku, int delta) {
throw new StockConflictException(sku, ex);
}
}
package com.shop.inventory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
private final InventoryService service;
public InventoryController(InventoryService service) {
this.service = service;
}
@PostMapping("/{sku}/adjust")
public StockView adjust(@PathVariable String sku, @RequestParam int delta) {
InventoryItem item = service.adjustStock(sku, delta);
return new StockView(item.getSku(), item.getAvailable(), item.getVersion());
}
@ExceptionHandler(StockConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ApiError onConflict(StockConflictException ex) {
return new ApiError("stock_conflict",
"Concurrent update lost the race for sku " + ex.getSku());
}
@ExceptionHandler(IllegalStateException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ApiError onInvariant(IllegalStateException ex) {
return new ApiError("invalid_stock_state", ex.getMessage());
}
@ExceptionHandler(UnknownSkuException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError onUnknown(UnknownSkuException ex) {
return new ApiError("unknown_sku", ex.getMessage());
}
public record StockView(String sku, int available, long version) {
}
public record ApiError(String code, String message) {
}
}
This snippet shows how to protect inventory quantity updates from lost writes under concurrent access using JPA's built-in optimistic locking, without holding database row locks. The core idea is that instead of SELECT ... FOR UPDATE, each write carries the version it read, and the database rejects the write if another transaction bumped that version in the meantime.
In InventoryItem entity, the @Version field version turns on optimistic locking for the aggregate. Hibernate automatically adds WHERE id = ? AND version = ? to every UPDATE and increments version on flush. If zero rows match, Hibernate throws an ObjectOptimisticLockingFailureException, meaning someone else won the race. The decrement and increment helpers keep the invariant (available >= 0) inside the entity so business rules live with the data, not scattered across services.
In InventoryService, adjustStock runs inside @Transactional and performs a read-modify-write: load the item, mutate it, and let the transaction commit trigger the versioned UPDATE. Crucially, the method is annotated with @Retryable on ObjectOptimisticLockingFailureException. When a conflict occurs the whole transaction rolls back and Spring Retry re-invokes the method with a fresh transaction, re-reading the now-current version. The short randomized backoff (@Backoff) spreads out competing retries so they don't collide again immediately. This is the correct pattern: never catch-and-continue a stale entity, always restart the unit of work from a fresh read.
The @Recover method recoverFromConflict defines the fallback once retries are exhausted, translating the failure into a domain-specific StockConflictException rather than leaking a persistence exception upward.
In InventoryController, adjust exposes the operation over REST and an @ExceptionHandler maps StockConflictException to HTTP 409 Conflict, which is the semantically correct status for a write that lost a concurrency race. It also maps IllegalStateException (thrown when stock would go negative) to 422.
Optimistic locking shines when contention is low-to-moderate and transactions are short, since it avoids lock contention and deadlocks entirely; the trade-off is wasted work on retry when contention is high. For hot single-row counters, pessimistic locking or an atomic UPDATE ... SET qty = qty - ? may outperform this. Note that @Retryable requires proxying, so the retried method must be called from another bean, and spring-retry plus @EnableRetry must be present.
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
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
Share this code
Here's the card — post it anywhere.