Produkcyjne demo wzorca Idempotency Key: bezpiecznie wątkowo, bez blokowania wątków, bez wywołań zewnętrznego API wewnątrz transakcji bazodanowej, na wątkach wirtualnych (Project Loom). Cały stos startuje jedną komendą docker compose up.
Więcej teorii: TEORIA.md.
Klient wysyła żądanie „zaksięguj płatność 199,99 PLN". Sieć się zawiesza - klient nie dostaje odpowiedzi. Co robi? Ponawia to samo żądanie.
Bez zabezpieczenia serwer potraktuje drugie żądanie jako nową operację - druga płatność, podwójne obciążenie.
Klient generuje unikalny identyfikator operacji (Idempotency Key) i wysyła go w nagłówku przy każdym żądaniu:
POST /api/v1/payments
Idempotency-Key: 7f3a9c2e-4b1d-4e8a-9f6c-1a2b3c4d5e6f
Content-Type: application/json
{"amount": 199.99, "currency": "PLN", "recipient": "ACME"}Serwer zapamiętuje: „ten klucz = ten wynik". Kolejne żądanie z tym samym kluczem i tą samą treścią dostaje ten sam wynik, bez ponownego wykonania operacji.
Ważne: klucz generuje klient (np. UUID), nie serwer. Dzięki temu klient może bezpiecznie ponowić request po timeoucie, używając tego samego klucza.
| Reguła | Co to znaczy |
|---|---|
| Klucz = operacja | Jeden klucz na jedną logiczną operację biznesową (np. jedna płatność w koszyku). |
| Ten sam klucz + ta sama treść | Idempotentny replay - zwracamy zapisany wynik (201, Idempotent-Replayed: true). |
| Ten sam klucz + inna treść | Nadużycie klucza - odrzucamy (422). Klient nie może „podmienić" kwoty pod istniejącym kluczem. |
- Wiele instancji serwisu - każda ma własną pamięć; synchronizacja musi być w bazie danych.
- Wyścig równoległy - dwa żądania z tym samym kluczem mogą trafić w ten sam moment; tylko jedno może „wygrać".
- Trwałość - po restarcie serwisu replay nadal musi działać.
W tym projekcie synchronizacją zajmuje się PRIMARY KEY na kolumnie idempotency_key - baza gwarantuje, że tylko jeden INSERT się powiedzie.
Nigdy nie wołaj zewnętrznego API (operator płatności) wewnątrz transakcji bazodanowej.
Wolne HTTP (setki ms-sekundy) trzyma otwarte połączenie do bazy, wyczerpuje pulę HikariCP i blokuje inne żądania. Dlatego aplikacja dzieli pracę na 3 fazy o rozdzielonych transakcjach (opisane poniżej).
Projekt składa się z dwóch niezależnych aplikacji Spring Boot (osobne pom.xml, osobne Dockerfile) orkiestrowanych przez Docker Compose:
idempotency-key-pattern/
├── payment-service/ # główny serwis (port 8080)
│ ├── src/main/java/com/app/
│ ├── src/main/resources/ schema.sql, data.sql, application.yaml
│ ├── pom.xml
│ └── Dockerfile
├── payment-gateway/ # atrapa operatora płatności (port 8081)
│ ├── src/main/java/com/app/
│ ├── pom.xml
│ └── Dockerfile
├── docker-compose.yml # MySQL + oba serwisy
├── README.md
├── README.en.md
└── TEORIA.md
| Serwis | Port | Rola |
|---|---|---|
payment-service |
8080 | API płatności, idempotencja, JPA + MySQL |
payment-gateway |
8081 | Wolny stub operatora (~800 ms), idempotentny per klucz |
mysql |
3306 | Baza idempotency |
Klient
│ POST + Idempotency-Key
▼
PaymentController ← presentation/
│ ProcessPaymentCommand
▼
ProcessPaymentUseCaseImpl ← infrastructure/usecase/ ← SEDNO (3 fazy)
│
├── IdempotencyTransactionBoundary ← infrastructure/tx/
│ └── IdempotencyPaymentService ← application/service/
│
└── PaymentGatewayPort ← application/port/output/
└── PaymentGatewayAdapter ← infrastructure/gateway/ (RestClient → :8081)
com.app
├── domain/ # rdzeń - model i repozytoria
│ ├── model/ Payment, IdempotencyLookup
│ ├── repository/ PaymentRepository, IdempotencyRecordRepository
│ └── exception/ wyjątki domenowe (409 / 422 / 502)
│
├── application/ # logika biznesowa
│ ├── service/ IdempotencyPaymentService
│ └── port/
│ ├── input/ ProcessPaymentUseCase, Command, Result
│ └── output/ PaymentGatewayPort
│
├── infrastructure/ # świat zewnętrzny
│ ├── usecase/ ProcessPaymentUseCaseImpl (3 fazy)
│ ├── tx/ IdempotencyTransactionBoundary
│ ├── configuration/ BeanConfiguration, GatewayProperties
│ ├── gateway/ adapter + DTO, RestClient do operatora
│ └── persistence/ entity, repository, adapter, mapper
│
└── presentation/ REST API
├── controller/ PaymentController
├── dto/ request/response DTO
└── exception/ GlobalExceptionHandler
Drugi serwis - payment-gateway (port 8081) - to atrapa wolnego operatora (~800 ms), sama też idempotentna (pamięta wynik per Idempotency-Key w pamięci). Dzięki temu system jest idempotentny end-to-end.
Implementacja w ProcessPaymentUseCaseImpl:
| Faza | Co robi | Transakcja? | I/O sieciowe? |
|---|---|---|---|
| 1. REZERWACJA | INSERT rekordu IN_PROGRESS |
TAK (krótka #1) | nie |
| 2. WYKONANIE | wywołanie wolnego operatora | NIE | TAK (tu i tylko tu) |
| 3. ZATWIERDZENIE | zapis płatności + COMPLETED |
TAK (krótka #2) | nie |
Dlaczego tak:
- Brak API w transakcji. Wolne wywołanie operatora (faza 2) nigdy nie trzyma otwartej transakcji ani połączenia do bazy.
- Bezpieczeństwo wątkowe = baza. Synchronizację daje klucz główny na
idempotency_key. Tylko jedno żądanie wygraINSERT; przegrani dostają błąd unikalności i są klasyfikowani. Działa też przy wielu instancjach. - Nie blokujemy. Równoległe żądanie z tym samym kluczem dostaje natychmiast 409.
- Bezpieczne ponawianie. Gdy operator zawiedzie, zwalniamy rezerwację, więc klient może ponowić z tym samym kluczem.
| Sytuacja | Status | Nagłówek / uwaga |
|---|---|---|
| Pierwsze żądanie | 201 |
Idempotent-Replayed: false |
| Powtórzenie (zakończone) | 201 |
Idempotent-Replayed: true |
| Równoległe, w trakcie | 409 |
nie blokujemy wątku |
| Ten sam klucz, inna treść | 422 |
nadużycie klucza |
| Błąd operatora | 502 |
rezerwacja zwolniona |
| Brak nagłówka / walidacja | 400 |
obsługa Spring Boot |
Wymagania: Docker + Docker Compose (do pełnego stosu). Do lokalnego buildu bez Dockera: JDK 25, Maven 3.9+.
docker compose up -d --buildZatrzymanie i usunięcie wolumenu z danymi:
docker compose down -vpayment-service czeka na mysql z condition: service_healthy. Jeśli przy starcie pojawi się błąd połączenia z bazą (Connection refused), poczekaj chwilę i uruchom ponownie:
docker compose restart payment-serviceW data.sql jest gotowy rekord seed-key-demo:
curl -i -X POST http://localhost:8080/api/v1/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: seed-key-demo" \
-d '{"amount": 49.99, "currency": "PLN", "recipient": "Sklep Demo"}'Oczekiwany wynik: 201, Idempotent-Replayed: true, ten sam paymentId przy każdym wywołaniu.
Endpoint: POST http://localhost:8080/api/v1/payments
Nagłówki:
Content-Type: application/jsonIdempotency-Key: <unikalny klucz - generuje klient>
Body:
{
"amount": 199.99,
"currency": "PLN",
"recipient": "ACME Corp"
}| Krok | Idempotency-Key | Body | Oczekiwany wynik |
|---|---|---|---|
| 1. Pierwsza płatność | test-001 |
jak wyżej | 201, Idempotent-Replayed: false |
| 2. Replay | test-001 |
to samo | 201, Idempotent-Replayed: true, ten sam paymentId |
| 3. Nadużycie klucza | test-001 |
inna kwota | 422 |
| 4. Wyścig | test-002 |
wyślij 2× szybko | 201 + 409 |
| 5. Po 409 | test-002 |
to samo, po ~2 s | 201, replay z pierwszego paymentId |
Używaj nowego klucza na każdą nową płatność. W produkcji zamiast test-001 stosuj UUID (crypto.randomUUID()).
| Temat | Plik |
|---|---|
| 3 fazy idempotencji | payment-service/.../ProcessPaymentUseCaseImpl.java |
| Granice transakcji | payment-service/.../infrastructure/tx/IdempotencyTransactionBoundary.java |
| Synchronizacja wyścigu (PK) | payment-service/src/main/resources/schema.sql |
Rezerwacja klucza (INSERT) |
payment-service/.../IdempotencyRecordRepositoryAdapter.java |
Persistable - wymuszenie INSERT |
payment-service/.../entity/IdempotencyRecordEntity.java |
UUID jako VARCHAR(36) |
payment-service/.../entity/PaymentEntity.java |
| Hash treści żądania | ProcessPaymentCommand.canonical() + DigestUtils.sha256Hex |
| Mapowanie błędów HTTP | payment-service/.../GlobalExceptionHandler.java |
| Wolny operator | payment-gateway/.../ChargeController.java |
| Virtual threads + RestClient | payment-service/.../BeanConfiguration.java |
| Orkiestracja Docker | docker-compose.yml |
| Warstwa | Technologie |
|---|---|
| Runtime | Java 25, Spring Boot 4.1.0, wątki wirtualne (Project Loom) |
| Web | spring-boot-starter-webmvc, spring-boot-starter-restclient, actuator |
| Persystencja | MySQL 9.7, JPA/Hibernate 7, schema.sql + data.sql (bez Flyway) |
| Narzędzia | Lombok, commons-codec, Docker / Docker Compose |
| Obrazy Docker | maven:3.9-eclipse-temurin-25, eclipse-temurin:25-jre |