← Back to list

Hybrid Locking in Spring Boot — Use @Lock and @Version Together for Safer Concurrent Updates

In many real backend systems, reading data and updating it is not as simple as it looks.

Noyan Germiyanoğlu · 2026-06-25 19:34 · 11 claps · 7.2 min read
#java #spring-boot #optimistic-locking #pessimistic-locking #hybrid
Open on Medium ↗
Wiki topics: 🌐 · Web Development 💑 · Relationships 📚 · Books & Reading

Hybrid Locking in Spring Boot — Use @Lock and @Version Together for Safer Concurrent Updates

In many real backend systems, reading data and updating it is not as simple as it looks.

A developer usually needs to think about:

  • concurrent requests
  • race conditions
  • stale updates
  • lost updates
  • stock consistency
  • money transfer safety
  • reservation conflicts

If we ignore concurrency, the application may work perfectly in local tests but fail under real production traffic.

For example:

Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(quantity);

This code looks fine.

But if two requests hit the same product at almost the same time, both may read the same stock value before either one commits.

That is where locking strategies become important.

A common solution is to use optimistic locking with @Version.

Another one is to use pessimistic locking with @Lock(PESSIMISTIC_WRITE).

Both are useful.

But in some critical cases, using them together gives a stronger and safer approach.

In this guide, we will build a small Spring Boot project that combines:

  • pessimistic locking for immediate row-level protection
  • optimistic versioning for an extra safety layer

The main goal is to create a clean and practical hybrid locking example for real-world concurrent update scenarios.

1) What you’ll build

We will create a small Spring Boot project named:

  • hybrid-lock-demo

The application will expose simple product endpoints:

GET  /api/products/{id}
POST /api/products/{id}/purchase

The business scenario is simple:

  • a product has stock
  • multiple requests may try to purchase the same product at the same time
  • we want to prevent data corruption and inconsistent updates

The final behavior will look like this:

  • request A locks the product row
  • request B waits
  • request A updates stock and commits
  • request B continues with the latest committed state

At the same time, the entity will also use @Version so that version-based conflict protection still exists as an additional layer.

2) Why this approach?

Using only optimistic locking is often enough for many systems.

But optimistic locking is usually best when conflicts are relatively rare.

Using only pessimistic locking can also work, especially when you want strict control over updates.

However, some business operations are more sensitive.

Examples:

  • stock reduction
  • wallet balance update
  • seat reservation
  • coupon usage
  • limited inventory purchase

In such cases, I like the hybrid approach.

Why?

Because:

  • PESSIMISTIC_WRITE protects the row immediately during the transaction
  • @Version adds another layer of protection at update time
  • the design is easier to reason about in critical flows
  • it reduces the chance of accidental concurrent write issues
  • it gives more confidence in production

This does not mean every update in every system should use both.

But for operations where consistency is more important than raw throughput, this approach can be a very good balance.

3) Tech Stack

We will use:

  • Java 25
  • Spring Boot
  • Spring Web
  • Spring Data JPA
  • PostgreSQL
  • Spring Validation

4) Project Structure

hybrid-lock-demo 
├─ pom.xml 
├─ src 
│ ├─ main 
│ │ ├─ java 
│ │ │ └─ com/example/hybridlockdemo 
│ │ │ ├─ HybridLockDemoApplication.java 
│ │ │ ├─ common 
│ │ │ │ └─ GlobalExceptionHandler.java 
│ │ │ └─ product 
│ │ │ ├─ DataInitializer.java 
│ │ │ ├─ Product.java 
│ │ │ ├─ ProductController.java 
│ │ │ ├─ ProductRepository.java 
│ │ │ ├─ ProductResponse.java 
│ │ │ ├─ ProductService.java 
│ │ │ └─ PurchaseRequest.java 
│ │ └─ resources 
│ │ └─ application.yml

4) Implementation

1️⃣ Product entity with @Version

The entity keeps the stock and version fields.

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer stock;

    @Version
    private Long version;

    protected Product() {
    }

    public Product(String name, Integer stock) {
        this.name = name;
        this.stock = stock;
    }

    public void decreaseStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be greater than zero");
        }

        if (this.stock < quantity) {
            throw new IllegalStateException("Not enough stock");
        }

        this.stock -= quantity;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Integer getStock() {
        return stock;
    }

    public Long getVersion() {
        return version;
    }
}

Why do we use @Version here?

Because versioning allows JPA to track update consistency at the entity level.

It is an additional safety mechanism that helps detect conflicting state changes.

2️⃣ Repository with PESSIMISTIC_WRITE

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdForUpdate(Long id);
}

This is the row-level protection point.

When a transaction fetches a product with this method, the database locks the row for writing.

If another transaction tries to update the same row, it waits.

3️⃣ Purchase request and response

public record PurchaseRequest(
        @Min(1)
        int quantity
) {
}
public record ProductResponse(
        Long id,
        String name,
        Integer stock,
        Long version
) {
}

4️⃣ Service layer

This is where the hybrid approach becomes active.

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public ProductResponse purchase(Long productId, PurchaseRequest request) {
        Product product = productRepository.findByIdForUpdate(productId)
                .orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));

        product.decreaseStock(request.quantity());

        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getStock(),
                product.getVersion()
        );
    }

    @Transactional(readOnly = true)
    public ProductResponse getById(Long productId) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));

        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getStock(),
                product.getVersion()
        );
    }
}

5️⃣ Controller

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public ProductResponse getById(@PathVariable Long id) {
        return productService.getById(id);
    }

    @PostMapping("/{id}/purchase")
    public ProductResponse purchase(@PathVariable Long id,
                                    @Valid @RequestBody PurchaseRequest request) {
        return productService.purchase(id, request);
    }
}

6️ Configuration

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/hybrid_lock_db
    username: postgres
    password: postgres

  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true

server:
  port: 8080

7️⃣ Sample startup data

@Component
public class DataInitializer implements CommandLineRunner {

    private final ProductRepository productRepository;

    public DataInitializer(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    public void run(String... args) {
        if (productRepository.count() == 0) {
            productRepository.save(new Product("Laptop", 10));
            productRepository.save(new Product("Keyboard", 20));
        }
    }
}

8️⃣ Global exception handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleEntityNotFound(EntityNotFoundException ex) {
        return new ErrorResponse(ex.getMessage());
    }

    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleBadRequest(RuntimeException ex) {
        return new ErrorResponse(ex.getMessage());
    }

    @ExceptionHandler(OptimisticLockException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleOptimisticLock(OptimisticLockException ex) {
        return new ErrorResponse("Optimistic lock conflict occurred");
    }

    @ExceptionHandler(PessimisticLockException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handlePessimisticLock(PessimisticLockException ex) {
        return new ErrorResponse("Pessimistic lock conflict occurred");
    }

    public record ErrorResponse(String message) {
    }
}

9️⃣ What happens when two requests arrive at the same time?

Two requests hit the endpoint at nearly the same time.

If the current product looks like this:

{
  "id": 1,
  "name": "Laptop",
  "stock": 10,
  "version": 0
}

and both requests succeed, the flow becomes:

  • Request A enters the transaction
  • Request A locks the row with PESSIMISTIC_WRITE
  • Request B tries to read the same row for update
  • Request B waits
  • Request A reduces stock from 10 to 7
  • Request A commits
  • the entity version becomes 1
  • Request B continues
  • Request B now sees the updated row
  • Request B reduces stock from 7 to 3
  • Request B commits
  • the entity version becomes 2

So both requests may succeed, but they do not overwrite each other.

That means the final committed state in the database will typically be:

{
  "id": 1,
  "name": "Laptop",
  "stock": 3,
  "version": 2
}

This is another nice benefit of the hybrid approach:

  • PESSIMISTIC_WRITE ensures that concurrent updates are serialized
  • @Version shows that each successful update still advances the entity version

One small note: if you return the entity before flush or commit, the API response may not always show the latest incremented version immediately. But after both transactions are committed, the database state should reflect version = 2.

🔟 Why not only use @Version?

Using only @Version is a good approach in many systems.

But it works differently.

With optimistic locking:

  • transactions do not block each other immediately
  • both may proceed until update/commit time
  • one may fail later because the version changed

That is often totally fine.

But in some business cases, I prefer the stricter control of locking the row first.

Examples:

  • limited stock
  • inventory reservation
  • balance update
  • one-time claim logic

In these flows, pessimistic locking makes the write behavior more explicit.

Adding @Version on top gives an extra layer of confidence.

1️⃣1️⃣ Run

After creating the project and preparing the parallel request scripts, you can start the application and test the concurrent update scenario.

Run the Spring Boot application

mvn clean spring-boot:run

The application will start on:

http://localhost:8080

Run the parallel request script on Windows

# Windows
# parallel_purchase_test.bat

# .\parallel_purchase_test.bat

@echo off
setlocal

echo Sending two parallel POST requests...

powershell -NoProfile -ExecutionPolicy Bypass -Command "$body1 = @{ quantity = 3 } | ConvertTo-Json -Compress; $body2 = @{ quantity = 4 } | ConvertTo-Json -Compress; $job1 = Start-Job -ScriptBlock { param($body) try { Invoke-RestMethod -Method POST -Uri 'http://localhost:8080/api/products/1/purchase' -ContentType 'application/json' -Body $body | ConvertTo-Json -Compress } catch { if ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()); $reader.ReadToEnd() } else { $_.Exception.Message } } } -ArgumentList $body1; $job2 = Start-Job -ScriptBlock { param($body) try { Invoke-RestMethod -Method POST -Uri 'http://localhost:8080/api/products/1/purchase' -ContentType 'application/json' -Body $body | ConvertTo-Json -Compress } catch { if ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()); $reader.ReadToEnd() } else { $_.Exception.Message } } } -ArgumentList $body2; Wait-Job -Job $job1,$job2 | Out-Null; Write-Host '--- Response 1 ---'; Receive-Job -Job $job1; Write-Host '--- Response 2 ---'; Receive-Job -Job $job2; Remove-Job -Job $job1,$job2"

echo.
echo Done.
pause

Run the parallel request script on Linux

# Linux
# parallel_purchase_test.sh

# chmod +x parallel_purchase_test.sh
# ./parallel_purchase_test.sh

#!/usr/bin/env bash
set -e

echo "Sending two parallel POST requests..."

curl -s -X POST "http://localhost:8080/api/products/1/purchase" \
  -H "Content-Type: application/json" \
  -d '{"quantity":3}' &

PID1=$!

curl -s -X POST "http://localhost:8080/api/products/1/purchase" \
  -H "Content-Type: application/json" \
  -d '{"quantity":4}' &

PID2=$!

wait $PID1
wait $PID2

echo
echo "Done."

Expected result

If the product initially has:

{
  "id": 1,
  "name": "Laptop",
  "stock": 10,
  "version": 0
}

and both requests succeed:

  • Request A purchases 3
  • Request B purchases 4

then the final database state will typically be:

{
  "id": 1,
  "name": "Laptop",
  "stock": 3,
  "version": 2
}

This shows that:

  • the row was protected by PESSIMISTIC_WRITE
  • both updates were serialized safely
  • the entity version increased after each successful commit

Here is the sample screenshot

🎯 Conclusion

The hybrid locking approach is a practical choice for business-critical update flows.

By combining PESSIMISTIC_WRITE with @Version, we protect the row during the transaction and still keep an extra consistency check at the entity level. One transaction locks the row, the other waits, and each successful update advances the entity version.

That gives you a design that is:

  • safe
  • explicit
  • realistic
  • ready for production

Happy Coding!

🤝Connect with Me


메타데이터
post_id
fc97cf2c79dd
slug
hybrid-locking-in-spring-boot-use-lock-and-version-together-for-safer-concurrent-updates-fc97cf2c79dd
url
https://medium.com/@sngermiyanoglu/hybrid-locking-in-spring-boot-use-lock-and-version-together-for-safer-concurrent-updates-fc97cf2c79dd
canonical_url
https://medium.com/@sngermiyanoglu/hybrid-locking-in-spring-boot-use-lock-and-version-together-for-safer-concurrent-updates-fc97cf2c79dd
author_url
https://medium.com/@sngermiyanoglu
status
ok
fetched_at
2026-06-29 01:02:39