A small Spring Boot project that demonstrates two classic concurrency problems in transactional systems and how to solve them at the database level:
- Pessimistic locking — preventing lost updates and overdrafts when several transactions touch the same row at the same time (the Wallet module).
- Duplicate-payment handling (idempotency) — guaranteeing that retried or concurrent payment requests carrying the same key result in exactly one charge (the Payment module).
Both examples are intentionally minimal so the concurrency mechanics stay in focus,
backed by PostgreSQL (real SELECT … FOR UPDATE and ON CONFLICT semantics).
| Component | Version |
|---|---|
| Java | 25 (toolchain) |
| Spring Boot | 4.1.0 |
| Spring Data JPA | bundled |
| PostgreSQL | 17 |
| Testcontainers | for the concurrency test |
| Gradle (Kotlin DSL) | wrapper included |
The application is split into two independent feature packages — wallet and
payment — each following the same Endpoint → Service → Repository → PostgreSQL
layering.
A wallet has a balance of 100. Two requests try to withdraw 100 at the same time.
Without coordination, both transactions read 100, both decide the balance is enough,
and both debit — leaving the balance at -100 (a lost update / overdraft).
WalletRepository.findById is annotated with @Lock(LockModeType.PESSIMISTIC_WRITE),
which makes JPA issue a SELECT … FOR UPDATE. The first transaction locks the row;
the second one blocks on the same row until the first commits, then reads the
already-updated balance and is rejected by validation.
| Class | Objective |
|---|---|
Wallet |
JPA entity mapped to the wallets table (id: UUID, balance: BigDecimal). |
WalletRepository |
Spring Data JPA repository. Overrides findById with @Lock(PESSIMISTIC_WRITE) so the read acquires a row-level write lock (SELECT … FOR UPDATE). Includes a commented raw-SQL walkthrough of the locking sequence. |
WalletService |
@Transactional business logic. Loads the wallet under the lock, validates the withdrawal (non-null, positive, sufficient balance) and debits it. |
WalletEndpoint |
REST controller exposing POST /wallets/{id}/withdraw. |
WalletConcurrencyTest |
Testcontainers-based test. Fires two simultaneous withdrawals against the same wallet and asserts exactly 1 success, 1 rejection, and a final balance of 0. |
# Wallet 642ad75f-… is seeded with 1000.00 (see docker/initdb/01-init.sql)
curl -X POST http://localhost:8080/wallets/642ad75f-3911-43f9-8193-209297783535/withdraw \
-H 'Content-Type: application/json' \
-d '{"amount": 100.00}'
# → 204 No Content (balance is now 900.00)A client retries a payment (network blip, double click, at-least-once delivery). Two
requests with the same Idempotency-Key must not produce two charges.
PaymentService.pay performs an atomic
INSERT … ON CONFLICT (idempotency_key) DO NOTHING. Only one request can create
the row; the others receive 0 rows created and instead read the existing payment,
returning a result that matches its stored status:
PROCESSING→409 Conflict(PaymentInProgressException) — a charge is still in flight.COMPLETED/FAILED→200 OKwith the stored payment — an idempotent replay, no second charge.
| Class | Objective |
|---|---|
Payment |
JPA entity mapped to payments (id, amount, idempotencyKey — unique, status). |
PaymentStatus |
Enum of the payment lifecycle: PROCESSING, COMPLETED, FAILED. |
PaymentRepository |
Spring Data JPA repository. insertIfAbsent runs the native INSERT … ON CONFLICT DO NOTHING and returns the affected row count; findByIdempotencyKey fetches an existing payment. |
PaymentService |
@Transactional idempotent pay. Decides between create-and-charge and return-existing based on whether the insert won the race and on the stored status. |
PaymentEndpoint |
REST controller exposing POST /payments, reading the Idempotency-Key request header. |
PaymentInProgressException |
Maps to HTTP 409 Conflict; thrown when a payment for the same key is still PROCESSING. |
A partial unique index (docker/initdb/02-payments.sql) adds a second guard:
CREATE UNIQUE INDEX idx_payments_completed_by_id ON payments (id) WHERE status = 'COMPLETED';# First call → creates and completes the payment
curl -i -X POST http://localhost:8080/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: abc-123' \
-d '{"amount": 49.90}'
# → 200 OK, status COMPLETED
# Same key again → idempotent replay, NOT a second charge
curl -i -X POST http://localhost:8080/payments \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: abc-123' \
-d '{"amount": 49.90}'
# → 200 OK, returns the same paymentdocker compose up -dThis starts PostgreSQL 17 and runs the scripts in docker/initdb/ to create the
wallets and payments tables and seed two wallets.
./gradlew bootRunThe app connects to the database described in
application.properties and listens on
http://localhost:8080.
./gradlew testThe wallet concurrency test spins up its own PostgreSQL via Testcontainers, so it
does not need the docker compose instance to be running (Docker must be available).
| Pessimistic lock (Wallet) | Idempotency key (Payment) | |
|---|---|---|
| Goal | Serialize writes to the same row | Deduplicate retried/concurrent requests |
| DB mechanism | SELECT … FOR UPDATE (PESSIMISTIC_WRITE) |
INSERT … ON CONFLICT DO NOTHING + UNIQUE |
| Loser of the race | Blocks, then re-reads fresh state | Reads existing row, returns by status |
| Failure surfaced as | Validation error (insufficient balance) | 409 Conflict or idempotent 200 OK |
src/main/java/.../springconcurrencyexamples
├── SpringConcurrencyExamplesApplication.java # Spring Boot entry point
├── wallet/ # Example 1 — pessimistic lock
│ ├── Wallet.java
│ ├── WalletRepository.java
│ ├── WalletService.java
│ └── WalletEndpoint.java
└── payment/ # Example 2 — idempotent payments
├── Payment.java
├── PaymentStatus.java
├── PaymentRepository.java
├── PaymentService.java
├── PaymentEndpoint.java
└── PaymentInProgressException.java
docker/initdb/ # schema + seed data executed on first container start
docs/images/ # SVG diagrams used in this README