java 146 lines · 3 tabs

Optimistic Locking on Inventory Updates with @Version and a Retrying Conflict Handler in Spring Boot

Shared by codesnips Jul 2026
3 tabs
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;
    }
}
3 files · java Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Optimistic Locking on Inventory Updates with @Version and a Retrying Conflict Handler in Spring Boot — share card
Link copied