Skip to content

lucaspaixao-dev/spring-concurrency-examples

Repository files navigation

spring-concurrency-examples

A small Spring Boot project that demonstrates two classic concurrency problems in transactional systems and how to solve them at the database level:

  1. Pessimistic locking — preventing lost updates and overdrafts when several transactions touch the same row at the same time (the Wallet module).
  2. 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).


Tech stack

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

Architecture overview

The application is split into two independent feature packages — wallet and payment — each following the same Endpoint → Service → Repository → PostgreSQL layering.

Architecture overview


Example 1 — Pessimistic lock (Wallet module)

The problem

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).

The solution

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.

Pessimistic lock flow

Classes

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.

Try it

# 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)

Example 2 — Duplicate-payment handling (Payment module)

The problem

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.

The solution

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:

  • PROCESSING409 Conflict (PaymentInProgressException) — a charge is still in flight.
  • COMPLETED / FAILED200 OK with the stored payment — an idempotent replay, no second charge.

Payment idempotency flow

Classes

Class Objective
Payment JPA entity mapped to payments (id, amount, idempotencyKeyunique, 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';

Try it

# 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 payment

Running locally

1. Start PostgreSQL

docker compose up -d

This starts PostgreSQL 17 and runs the scripts in docker/initdb/ to create the wallets and payments tables and seed two wallets.

2. Run the application

./gradlew bootRun

The app connects to the database described in application.properties and listens on http://localhost:8080.

3. Run the tests

./gradlew test

The 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).


How the two patterns compare

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

Project layout

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

About

This repo shows how to work with concurrency using spring boot

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages