Skip to content

feat: implement wallet transfer service with idempotency and pessimistic locking#93

Open
Shreerang-bot wants to merge 1 commit into
Robustrade:mainfrom
Shreerang-bot:solution/shreerang
Open

feat: implement wallet transfer service with idempotency and pessimistic locking#93
Shreerang-bot wants to merge 1 commit into
Robustrade:mainfrom
Shreerang-bot:solution/shreerang

Conversation

@Shreerang-bot

Copy link
Copy Markdown

Overview

This PR introduces the core wallet transfer service, built with Java, Spring Boot, and PostgreSQL, designed to handle concurrent transactions safely and idempotently.

Database Schema

  • 4 Tables: wallets, transfers, ledger_entries, idempotency_records
  • UNIQUE constraint on idempotency_key to enforce exactly-once processing
  • CHECK constraint on balance to prevent negative wallet balances at the DB level
  • Foreign Key constraints with RESTRICT on ledger entries for referential integrity
  • Indexes added on idempotency keys and wallet IDs for fast lookups

Idempotency Strategy

Stored in idempotency_records inside the same @Transactional block. The UNIQUE constraint handles concurrent duplicate races — on collision, DataIntegrityViolationException is caught and the existing result is read and returned.

Concurrency Strategy

  • @Lock(PESSIMISTIC_WRITE) on wallet fetch prevents concurrent modification anomalies
  • Locks acquired in ascending wallet ID order to eliminate deadlocks
  • Spring @Transactional sets the atomic boundary across wallets and ledger entries

Trade-offs

  • Stored balance vs derived: chose stored for O(1) reads
  • Pessimistic vs optimistic locking: chose pessimistic due to high contention on shared wallets
  • JPA vs raw JDBC: chose JPA for entity lifecycle, native query for deterministic lock ordering

AI Usage

Used AntiGravity for:

Requirements doc (Prompt 1):
You are a senior backend engineer. I am about to implement a wallet
transfer service in Java + Spring Boot + PostgreSQL. Before writing any
code, help me write a requirements document that covers:

  1. Problem statement (wallet-to-wallet transfer API)
  2. API contract: POST /transfers with idempotencyKey, fromWalletId,
    toWalletId, amount
  3. Expected behavior for happy path and duplicate requests
  4. Failure modes: insufficient funds, invalid wallet,
    concurrent double-spend
  5. Idempotency behavior: how duplicate keys are handled,
    what happens on network failure or process crash
  6. Consistency expectations: double-entry ledger, atomic transfers
  7. Testing strategy: what scenarios must be covered

Output the document in markdown format with clear sections

Schema design (Prompt 2):
Based on this requirements document [paste doc from Prompt 1], design a
PostgreSQL schema for the wallet transfer service. Include:

  • wallets table with balance and a CHECK constraint (balance >= 0)
  • transfers table with idempotency_key (UNIQUE constraint), status
    ENUM (PENDING, PROCESSED, FAILED), from_wallet_id FK, to_wallet_id FK
  • ledger_entries table with type ENUM (DEBIT, CREDIT), FK to transfers
    with ON DELETE RESTRICT, FK to wallets
  • idempotency_records table storing response_body (JSON) and status_code,
    keyed by idempotency_key (PRIMARY KEY)

For each table explain:

  • Purpose of each column
  • Which indexes to create and why
  • Which constraints prevent data corruption

Also provide the Flyway migration SQL (V1__init.sql)

Concurrency strategy (Prompt 3):
I need to implement concurrent-safe wallet transfers in PostgreSQL with
Java + Spring Boot. Two users might simultaneously debit the same wallet.

Write the transaction logic (pseudocode + SQL) that:

  1. Acquires row-level locks on both wallets using SELECT FOR UPDATE
  2. Always locks in ascending wallet_id order to prevent deadlocks
  3. Checks sufficient funds AFTER acquiring both locks
  4. Atomically: debits source wallet, credits destination wallet,
    inserts DEBIT ledger entry, inserts CREDIT ledger entry,
    updates transfer status to PROCESSED
  5. If insufficient funds: updates transfer status to FAILED,
    rolls back balance changes, keeps the transfer record

Explain why the lock ordering prevents deadlocks specifically in the
scenario where transfer A locks wallet 1 then wallet 2, while transfer B
tries to lock wallet 2 then wallet 1.

Idempotency implementation (Prompt 4):
Implement the idempotency layer for the wallet transfer service in
Java + Spring Boot. I have a POST /transfers endpoint. The flow:

  1. Extract idempotencyKey from the request body
  2. Query idempotency_records table — if found, deserialize and return
    the stored response immediately (no business logic runs)
  3. If not found, execute the transfer inside a @transactional block
  4. Before the transaction commits, persist the response into
    idempotency_records within the SAME transaction
  5. Return the response

Handle these edge cases:

  • Two simultaneous requests with the same key both miss the check:
    catch DataIntegrityViolationException from the UNIQUE constraint,
    then re-query idempotency_records and return the stored response
  • Process crash after commit but before responding: next retry reads
    from idempotency_records and returns the exact stored response
  • Duplicate requests return the same HTTP status code as the original

Use Spring @transactional, a JpaRepository for IdempotencyRecord,
and store responseBody as a JSON string (use ObjectMapper).
Show the full TransferService code with comments.

Architecture scaffold (Prompt 5):
Scaffold the complete package structure and code for the wallet transfer
service using Java + Spring Boot + Spring Data JPA + PostgreSQL.
Apply clean layered architecture:

controller/TransferController.java

  • Thin: only HTTP concerns, Bean Validation, calls service
  • Never touches repositories directly

service/TransferService.java

  • All business logic, idempotency orchestration, state transitions
  • Owns the @transactional boundary

repository/WalletRepository.java

  • JpaRepository + custom findByIdWithLock using
    @lock(LockModeType.PESSIMISTIC_WRITE)
  • No @transactional on individual methods

repository/TransferRepository.java — JpaRepository for transfers
repository/LedgerEntryRepository.java — JpaRepository for ledger entries
repository/IdempotencyRecordRepository.java — JpaRepository for idempotency

domain/Wallet.java

  • JPA entity with debit(amount) and credit(amount) methods
  • debit() throws InsufficientFundsException if balance < amount

domain/Transfer.java

  • JPA entity with status enum and markProcessed() / markFailed() methods

dto/TransferRequest.java — request DTO with @NotNull, @positive annotations
dto/TransferResponse.java — response DTO
exception/InsufficientFundsException.java
exception/GlobalExceptionHandler.java — @ControllerAdvice

Rule: Controllers never touch repos. Service owns @transactional.
Repos must NOT start their own transactions.

Test cases (Prompt 6):
Write integration tests for the wallet transfer service using
Java + Spring Boot + JUnit 5 + Testcontainers (PostgreSQLContainer).

Cover these 5 scenarios:

  1. Happy path

    • Transfer succeeds
    • Source wallet balance reduced by amount
    • Destination wallet balance increased by amount
    • Exactly 2 ledger entries created (1 DEBIT + 1 CREDIT)
    • Transfer status = PROCESSED
  2. Idempotency

    • POST /transfers with idempotencyKey twice
    • Both return identical response body and status code
    • Only 1 transfer record in DB
    • Only 2 ledger entries in DB (not 4)
  3. Insufficient funds

    • Transfer amount > source wallet balance
    • Returns 422 or 400 with error message
    • Both wallet balances unchanged
    • Zero ledger entries created
    • Transfer status = FAILED
  4. Concurrent transfers (race condition test)

    • Create wallet with balance 1000
    • Submit 10 simultaneous transfers of 200 each via ExecutorService
    • After all complete: assert final balance >= 0 (never negative)
    • Assert sum of successful transfer amounts = balance reduction
    • Assert no transfer resulted in overdraft
  5. Double-entry ledger invariant

    • After every successful transfer:
      SUM(amount WHERE type=DEBIT) == SUM(amount WHERE type=CREDIT)

Use @SpringBootTest, real PostgreSQLContainer (not H2).
Use @transactional with Propagation.REQUIRES_NEW for test isolation.
Explain why H2 in-memory DB would NOT catch concurrency bugs.

Reviewed and modified every output.

…tic locking

Overview
This PR introduces the core wallet transfer service, built with Java, Spring Boot, and PostgreSQL, designed to handle concurrent transactions safely and idempotently.

Database Schema
4 Tables: Configured tables for wallets, ledger entries, and idempotency records.
Key Constraints:
UNIQUE constraint on idempotency_key to enforce exactly-once processing.
CHECK constraint on balance to prevent negative wallet balances at the database level.
Foreign Key constraints with RESTRICT on ledger entries to ensure referential integrity.
Indexes: Added to optimize lookups on idempotency keys and wallet IDs.
Idempotency Strategy
Idempotency is guaranteed by storing requests in idempotency_records within the same @transactional boundary as the transfer logic. The UNIQUE constraint handles concurrent duplicate races; if a collision occurs, a DataIntegrityViolationException is caught, and the existing result is read and returned.

Concurrency Strategy
Locking: Uses @lock(PESSIMISTIC_WRITE) on wallet fetch to prevent concurrent modification anomalies.
Lock Ordering: Locks are consistently acquired in ascending wallet ID order to eliminate the possibility of deadlocks during simultaneous transfers between the same two wallets.
Transaction Boundary: Governed by Spring's @transactional to ensure atomic state transitions across wallets and ledgers.
Trade-offs
Stored Balance vs. Derived: Chose stored balance for O(1) read performance, trading off the space and update complexity of maintaining it over deriving from the ledger.
Pessimistic vs. Optimistic Locking: Chose pessimistic locking due to the anticipated high contention on shared wallets, preferring to block at the database level rather than retrying frequently in the application.
JPA vs. Raw JDBC: Chose JPA for developer velocity and entity lifecycle management, but fell back to native queries specifically for lock ordering to ensure deterministic lock acquisition.
AI Usage
Used AntiGravity for the development lifecycle:

Requirements doc (Prompt 1)
Schema design (Prompt 2)
Concurrency strategy (Prompt 3)
Idempotency implementation (Prompt 4)
Architecture scaffold (Prompt 5)
Test cases (Prompt 6)
Reviewed and modified every output.
Copilot AI review requested due to automatic review settings June 19, 2026 09:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR scaffolds a wallet-to-wallet transfer API using Spring Boot + JPA + Flyway, aiming to provide idempotent transfer execution with pessimistic row locking and a double-entry ledger persisted in PostgreSQL (plus an H2 test profile).

Changes:

  • Added initial PostgreSQL + H2 test schemas for wallets/transfers/ledger_entries/idempotency_records via Flyway.
  • Implemented the transfer execution flow with idempotency claiming, deterministic lock ordering, balance updates, and ledger entry creation.
  • Added an integration test suite and a test profile configuration.

Reviewed changes

Copilot reviewed 35 out of 36 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/main/resources/db/migration/V1__init_schema.sql Introduces the PostgreSQL schema (types + tables + constraints + indexes).
src/test/resources/db/migration-h2/V1__init_schema.sql Adds an H2-compatible schema for tests (Postgres compatibility mode).
src/main/resources/application.yml Configures Postgres datasource, Flyway, JPA validate, and logging/actuator.
src/test/resources/application-test.yml Configures the test profile datasource and Flyway location.
src/main/java/com/wallet/transferservice/TransferServiceApplication.java Boots the application and enables scheduling.
src/main/java/com/wallet/transferservice/controller/TransferController.java Exposes POST /transfers and maps replay/new results to HTTP status.
src/main/java/com/wallet/transferservice/service/TransferService.java Implements idempotency + locking + wallet/ledger persistence in one flow.
src/main/java/com/wallet/transferservice/repository/*.java Adds JPA repositories including pessimistic wallet lock query and ledger sums.
src/main/java/com/wallet/transferservice/domain/*.java Adds JPA entities for Wallet/Transfer/LedgerEntry/IdempotencyRecord.
src/main/java/com/wallet/transferservice/domain/enums/*.java Adds enums for wallet/transfer/ledger entry state.
src/main/java/com/wallet/transferservice/exception/*.java Adds domain exceptions and an API exception handler.
src/main/java/com/wallet/transferservice/job/IdempotencyCleanupJob.java Adds scheduled cleanup for expired idempotency records.
src/test/java/com/wallet/transferservice/TransferServiceIntegrationTest.java Adds integration-style tests for transfers, idempotency, concurrency, and invariants.
pom.xml Adds Spring Boot + JPA + Flyway + Postgres + H2 + Testcontainers dependencies.
mvnw, mvnw.cmd, .mvn/wrapper/maven-wrapper.properties, .gitattributes, .gitignore Adds build wrapper + repo config housekeeping.

Comment on lines +1 to +6
-- =============================================================================
-- ENUM TYPES
-- =============================================================================
CREATE TYPE wallet_status AS ENUM ('ACTIVE', 'FROZEN', 'CLOSED');
CREATE TYPE transfer_status AS ENUM ('COMPLETED', 'FAILED');
CREATE TYPE ledger_entry_type AS ENUM ('DEBIT', 'CREDIT');
Comment on lines +72 to +75
transfer_id UUID REFERENCES transfers(id) ON DELETE RESTRICT,
http_status_code SMALLINT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
Comment on lines +89 to +103
try {
idempotencyRecordRepository.saveAndFlush(idempotencyRecord);
} catch (DataIntegrityViolationException e) {
// Another concurrent request inserted the same key between our SELECT and INSERT.
// Re-read and either replay or report in-flight.
log.info("Idempotency key race detected for key={}", idempotencyKey);
var raceRecord = idempotencyRecordRepository.findById(idempotencyKey).orElse(null);
if (raceRecord != null && raceRecord.isCompleted()) {
if (!raceRecord.parametersMatch(fromWalletId, toWalletId, amount)) {
throw new IdempotencyKeyConflictException(request.idempotencyKey());
}
return new TransferResult(deserializeResponse(raceRecord.getResponseBody()), true);
}
throw new TransferInFlightException(request.idempotencyKey());
}
Comment on lines +132 to +149
// ── Step 4: Validate wallet status ────────────────────
if (sourceWallet.getStatus() != WalletStatus.ACTIVE) {
cleanupIdempotencyRecord(idempotencyKey);
throw new WalletInactiveException(fromWalletId);
}
// Destination can be ACTIVE or FROZEN (can receive but not send)
if (destWallet.getStatus() == WalletStatus.CLOSED) {
cleanupIdempotencyRecord(idempotencyKey);
throw new WalletInactiveException(toWalletId);
}

// ── Step 5: Debit source (enforces sufficient funds), credit destination ──
try {
sourceWallet.debit(amount);
} catch (InsufficientFundsException e) {
cleanupIdempotencyRecord(idempotencyKey);
throw e;
}
Comment on lines +143 to +158
// ── Step 5: Debit source (enforces sufficient funds), credit destination ──
try {
sourceWallet.debit(amount);
} catch (InsufficientFundsException e) {
cleanupIdempotencyRecord(idempotencyKey);
throw e;
}

destWallet.credit(amount);

walletRepository.save(sourceWallet);
walletRepository.save(destWallet);

// ── Step 7: Insert transfer record ────────────────────
Transfer transfer = new Transfer(idempotencyKey, fromWalletId, toWalletId, amount);
transfer = transferRepository.save(transfer);
Comment on lines +27 to +33
@PostMapping
public ResponseEntity<?> createTransfer(@Valid @RequestBody TransferRequest request) {
TransferResult result = transferService.executeTransfer(request);

HttpStatus status = result.replay() ? HttpStatus.OK : HttpStatus.CREATED;
return ResponseEntity.status(status).body(result.response());
}
Comment on lines +30 to +106
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private TransferStatus status = TransferStatus.COMPLETED;

@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@PrePersist
protected void onCreate() {
this.createdAt = Instant.now();
}

// ── Constructors ──────────────────────────────────────────

public Transfer() {
}

public Transfer(UUID idempotencyKey, UUID fromWalletId, UUID toWalletId, BigDecimal amount) {
this.idempotencyKey = idempotencyKey;
this.fromWalletId = fromWalletId;
this.toWalletId = toWalletId;
this.amount = amount;
this.status = TransferStatus.COMPLETED;
}

// ── Getters & Setters ─────────────────────────────────────

public UUID getId() {
return id;
}

public void setId(UUID id) {
this.id = id;
}

public UUID getIdempotencyKey() {
return idempotencyKey;
}

public void setIdempotencyKey(UUID idempotencyKey) {
this.idempotencyKey = idempotencyKey;
}

public UUID getFromWalletId() {
return fromWalletId;
}

public void setFromWalletId(UUID fromWalletId) {
this.fromWalletId = fromWalletId;
}

public UUID getToWalletId() {
return toWalletId;
}

public void setToWalletId(UUID toWalletId) {
this.toWalletId = toWalletId;
}

public BigDecimal getAmount() {
return amount;
}

public void setAmount(BigDecimal amount) {
this.amount = amount;
}

public TransferStatus getStatus() {
return status;
}

public void markFailed() {
if (this.status == TransferStatus.COMPLETED) {
throw new IllegalStateException("Cannot fail a completed transfer");
}
this.status = TransferStatus.FAILED;
}
* Delete expired idempotency records. Called by a scheduled cleanup job.
*/
@Modifying
@Query("DELETE FROM IdempotencyRecord r WHERE r.expiresAt < :cutoff")
* Sum of ledger entry amounts for a specific wallet.
* Must always equal the wallet's materialized balance.
*/
@Query("SELECT COALESCE(SUM(e.amount), 0) FROM LedgerEntry e WHERE e.walletId = :walletId")
Comment on lines +1 to +18
spring:
datasource:
url: jdbc:h2:mem:wallet_test_db;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:

jpa:
hibernate:
ddl-auto: none
open-in-view: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect

flyway:
enabled: true
locations: classpath:db/migration-h2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants