Spring BootOptimistic Locking
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);
    }
}