Preventing Double-Spend in Spring Boot 3 Using Pessimistic SERIALIZABLE Locking

java dev.to

Preventing Double-Spend in Spring Boot 3 Using Pessimistic SERIALIZABLE Locking


The Problem Nobody Warns You About

You build a fintech feature. It works perfectly in testing.

Then you go live.

A payment provider like OPay or Stripe retries its webhook — because networks are unreliable and they want to guarantee delivery. Now you have three copies of the same webhook hitting your service at the same millisecond.

Your wallet gets credited three times. Your user is now rich. Your company is not.

This is double-spend. And it's not a rare edge case — it's guaranteed to happen in production.


Why This Is Harder Than It Looks

The obvious fix is to check before you credit:

// DANGEROUS — don't do this
if (!alreadyCredited(referenceId)) {
    walletService.credit(userId, amount);
}
Enter fullscreen mode Exit fullscreen mode

The check and the credit are two separate operations. Between them, another thread can pass the same check. Both threads credit the wallet. You've gained nothing.

This is a check-then-act race condition, and a standard @Transactional annotation on its own will not save you.

By default, Spring uses READ_COMMITTED isolation. Threads can still read the same "not yet credited" state from each other before either one commits. You need to go further.


What Actually Happened in VerifiedCore

Building VerifiedCore — a virtual number verification platform on Spring Boot 3 + PostgreSQL — I hit this exact bug during live testing.

I fired three concurrent webhook deliveries at the same payment reference:

  • The wallet correctly credited exactly once — the locking worked ✅
  • But the payment_transactions row ended up showing FAILED

Why? A slower concurrent attempt's failure-path write committed after the successful attempt's write. The last writer won, and it wrote the wrong status.

Two separate races. Two different fixes.


Fix 1: Lock the Wallet Row with Pessimistic SERIALIZABLE

The wallet-service needed to guarantee that only one credit attempt per referenceId could succeed, no matter how many concurrent requests came in.

Step 1 — Add a locking query to your repository

public interface WalletRepository extends JpaRepository<Wallet, UUID> {

    // SELECT ... FOR UPDATE — acquires an exclusive row-level lock.
    // Every other transaction trying to read this row will BLOCK until we commit.
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT w FROM Wallet w WHERE w.userId = :userId")
    Optional<Wallet> findByUserIdForUpdate(@Param("userId") UUID userId);
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Run the credit inside a SERIALIZABLE transaction

@Service
public class WalletService {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public CreditResult credit(UUID userId, BigDecimal amount, String referenceId) {

        // Safe to check here — PESSIMISTIC_WRITE locks the row before this read.
        // No other transaction can slip in between the check and the credit.
        if (walletTransactionRepo.existsByReferenceId(referenceId)) {
            return CreditResult.ALREADY_CREDITED;
        }

        Wallet wallet = walletRepo
            .findByUserIdForUpdate(userId)  // acquires the exclusive lock
            .orElseThrow();

        wallet.setBalanceUsd(wallet.getBalanceUsd().add(amount));
        walletRepo.save(wallet);

        // Record the referenceId so future concurrent attempts bail out above.
        walletTransactionRepo.save(
            WalletTransaction.builder()
                .walletId(wallet.getId())
                .amountUsd(amount)
                .referenceId(referenceId)
                .build()
        );

        return CreditResult.SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

What this buys you:

  • PESSIMISTIC_WRITE → database issues SELECT ... FOR UPDATE on the wallet row
  • Thread 1 acquires the lock. Threads 2 and 3 block — they don't race, they wait
  • When thread 1 commits, threads 2 and 3 wake up, see existsByReferenceId = true, and return ALREADY_CREDITED
  • SERIALIZABLE isolation also blocks phantom reads — where a row inserted by another transaction is invisible to your current snapshot, letting a second thread pass the same existence check

Live test result with three concurrent webhooks:

Thread 1 → CREDIT_SUCCESS    (wallet: $0.00 → $57.50)
Thread 2 → ALREADY_CREDITED  (no-op)
Thread 3 → ALREADY_CREDITED  (no-op)
Enter fullscreen mode Exit fullscreen mode

Exactly what you want.


Fix 2: Stop the Last Writer from Corrupting the Payment Row

The wallet was safe. But the payment_transactions status row wasn't.

Thread 1 wrote status = COMPLETED. Thread 3's failure path wrote status = FAILED 200ms later — after thread 1 had already committed. Last writer wins. Wrong status persists.

The fix is optimistic locking on the payment entity. One annotation:

@Entity
@Table(name = "payment_transactions")
public class PaymentTransaction {

    @Id
    private UUID id;

    private String status;

    // Hibernate increments this on every UPDATE.
    // If two threads try to update the same row version, the slower one
    // throws ObjectOptimisticLockingFailureException instead of silently
    // overwriting the winner's result.
    @Version
    private Long version;

    // ... other fields
}
Enter fullscreen mode Exit fullscreen mode

That's it. Hibernate now generates:

UPDATE payment_transactions
SET status = 'FAILED', version = 2
WHERE id = ? AND version = 1  -- rejected if version already moved past 1
Enter fullscreen mode Exit fullscreen mode

If thread 1 committed version 1 → 2 (writing COMPLETED), thread 3's update targets version = 1 — which no longer exists. PostgreSQL rejects it. No silent overwrite.

One important rule: don't try to catch and recover inside the same @Transactional method. Once Hibernate throws an optimistic lock failure, the persistence context is in an unusable state. Let it propagate to your controller:

@PostMapping("/webhooks/payment/opay")
public ResponseEntity<?> handleWebhook(@RequestBody String payload) {
    try {
        paymentService.processConfirmedPayment(reference);
        return ResponseEntity.ok().build();
    } catch (ObjectOptimisticLockingFailureException e) {
        // The row was already updated by a faster concurrent request.
        // The important guarantee (no overwrite) is already met.
        // Return 200 so the provider doesn't retry and compound the problem.
        log.warn("Stale concurrent webhook for {}: ignoring", reference);
        return ResponseEntity.ok().build();
    }
}
Enter fullscreen mode Exit fullscreen mode

The Two-Layer Pattern

These two mechanisms solve different races:

Layer Mechanism Protects
Wallet balance PESSIMISTIC_WRITE + SERIALIZABLE No duplicate credit to the ledger
Payment status @Version (optimistic lock) No stale FAILED overwrite on the transaction row
  • Pessimistic locking blocks threads upfront. Use it where you can't tolerate any concurrent modification — wallet balances, escrow holds, inventory.
  • Optimistic locking lets threads race and rejects the loser at commit time. Use it where conflicts are rare — status fields, audit rows, idempotency metadata.

One More Gotcha: Truncate Your Error Messages

When the serialization error fired in VerifiedCore, PaymentService tried to store the raw JDBC exception text into a varchar(500) column.

The exception text was longer than 500 characters. The insert crashed. The transaction row got stuck in PENDING forever — not even FAILED.

Always truncate before storing:

private static String truncate(String s, int max) {
    // Postgres throws DataException if you exceed column length.
    // Truncate here so the row always persists, even on ugly errors.
    return (s != null && s.length() > max)
        ? s.substring(0, max - 3) + "..."
        : s;
}
Enter fullscreen mode Exit fullscreen mode

Ship the feature. Then ship the error handling.


Pre-Ship Checklist

  • [ ] Every wallet credit checks referenceId inside the locked transaction — not before acquiring the lock
  • [ ] Your @Transactional isolation is SERIALIZABLE on the write path, not the Spring default READ_COMMITTED
  • [ ] Your payment entity has a @Version column backed by a real DB column (bigint NOT NULL DEFAULT 0)
  • [ ] Your webhook controller catches ObjectOptimisticLockingFailureException and returns 200 — not 500
  • [ ] Your referenceId column has a UNIQUE index — the database is your last line of defense
  • [ ] Your error-reason columns are long enough, or you're truncating before save

Built and battle-tested while developing VerifiedCore — a virtual number verification platform for developers.

Source: dev.to

arrow_back Back to Tutorials