Real World Example

Paytm-style Wallet Transfer

Moving money between two wallets is the textbook transaction: debit one, credit the other, record the transfer, all or nothing. A failed attempt must still be logged for fraud checks, so the attempt log uses REQUIRES_NEW.

@Service
@RequiredArgsConstructor
public class WalletService {

    private final WalletRepository wallets;
    private final TransferRepository transfers;
    private final TransferAttemptLogger attempts;     // separate bean -> REQUIRES_NEW works

    @Transactional(isolation = Isolation.READ_COMMITTED, timeout = 3,
                   rollbackFor = InsufficientBalanceException.class)
    public Transfer transfer(Long fromId, Long toId, BigDecimal amount) throws InsufficientBalanceException {
        attempts.log(fromId, toId, amount);                         // committed even if we roll back

        int debited = wallets.debitIfEnough(fromId, amount);       // atomic UPDATE ... where balance >= ?
        if (debited == 0) throw new InsufficientBalanceException(fromId, amount);
        wallets.credit(toId, amount);
        return transfers.save(new Transfer(fromId, toId, amount));
    }
}

public interface WalletRepository extends JpaRepository<Wallet, Long> {
    @Modifying
    @Query("update Wallet w set w.balance = w.balance - :amt where w.id = :id and w.balance >= :amt")
    int debitIfEnough(@Param("id") Long id, @Param("amt") BigDecimal amt);

    @Modifying
    @Query("update Wallet w set w.balance = w.balance + :amt where w.id = :id")
    int credit(@Param("id") Long id, @Param("amt") BigDecimal amt);
}

Success and Failure

Failed transfer

curl -X POST localhost:8080/api/wallet/transfers -H "Content-Type: application/json" \
     -d '{"from":11,"to":42,"amount":5000}'

Example output

-- wallet 11 has only Rs.1200
DEBUG o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.shopease.wallet.WalletService.transfer]: PROPAGATION_REQUIRED,ISOLATION_READ_COMMITTED,timeout_3,-com.shopease.wallet.InsufficientBalanceException
DEBUG o.s.orm.jpa.JpaTransactionManager : Suspending current transaction, creating new transaction with name [com.shopease.wallet.TransferAttemptLogger.log]
DEBUG org.hibernate.SQL : insert into transfer_attempts (amount,from_wallet,to_wallet) values (?,?,?)
DEBUG o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
DEBUG o.s.orm.jpa.JpaTransactionManager : Resuming suspended transaction after completion of inner transaction
DEBUG org.hibernate.SQL : update wallets set balance=(balance-?) where id=? and balance>=?
DEBUG o.s.orm.jpa.JpaTransactionManager : Initiating transaction rollback

HTTP/1.1 422 Unprocessable Entity
{"type":"about:blank","title":"Insufficient balance","status":422,"detail":"Wallet 11 cannot send Rs.5000.00"}

Rules Used by Payment Teams

  • One use case = one transaction, started in the service layer.
  • Atomic conditional UPDATEs (or @Version) instead of read-check-write.
  • REQUIRES_NEW only for records that must outlive a rollback; keep them tiny.
  • Explicit rollbackFor when business exceptions are checked.
  • Short timeouts, and never call external APIs (payment gateway, SMS) inside the DB transaction: call them before, or after commit with @TransactionalEventListener (see the Spring Boot advance level).