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.
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_WRITEprotects the row immediately during the transaction@Versionadds 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
10to7 - Request A commits
- the entity version becomes
1 - Request B continues
- Request B now sees the updated row
- Request B reduces stock from
7to3 - 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_WRITEensures that concurrent updates are serialized@Versionshows 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
- GitHub: My Github Profile
- LinkedIn: My Linkedln Profile
- Stackoverflow: My Stackoverflow Profile
메타데이터
- 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