feat: implement wallet transfer service with idempotency and pessimistic locking#93
Open
Shreerang-bot wants to merge 1 commit into
Open
feat: implement wallet transfer service with idempotency and pessimistic locking#93Shreerang-bot wants to merge 1 commit into
Shreerang-bot wants to merge 1 commit into
Conversation
…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.
There was a problem hiding this comment.
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
idempotency_keyto enforce exactly-once processingbalanceto prevent negative wallet balances at the DB levelRESTRICTon ledger entries for referential integrityIdempotency Strategy
Stored in
idempotency_recordsinside the same@Transactionalblock. TheUNIQUEconstraint handles concurrent duplicate races — on collision,DataIntegrityViolationExceptionis caught and the existing result is read and returned.Concurrency Strategy
@Lock(PESSIMISTIC_WRITE)on wallet fetch prevents concurrent modification anomalies@Transactionalsets the atomic boundary across wallets and ledger entriesTrade-offs
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:
toWalletId, amount
concurrent double-spend
what happens on network failure or process crash
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:
ENUM (PENDING, PROCESSED, FAILED), from_wallet_id FK, to_wallet_id FK
with ON DELETE RESTRICT, FK to wallets
keyed by idempotency_key (PRIMARY KEY)
For each table explain:
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:
inserts DEBIT ledger entry, inserts CREDIT ledger entry,
updates transfer status to PROCESSED
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:
the stored response immediately (no business logic runs)
idempotency_records within the SAME transaction
Handle these edge cases:
catch DataIntegrityViolationException from the UNIQUE constraint,
then re-query idempotency_records and return the stored response
from idempotency_records and returns the exact stored response
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
service/TransferService.java
repository/WalletRepository.java
@lock(LockModeType.PESSIMISTIC_WRITE)
repository/TransferRepository.java — JpaRepository for transfers
repository/LedgerEntryRepository.java — JpaRepository for ledger entries
repository/IdempotencyRecordRepository.java — JpaRepository for idempotency
domain/Wallet.java
domain/Transfer.java
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:
Happy path
Idempotency
Insufficient funds
Concurrent transfers (race condition test)
Double-entry ledger invariant
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.