Spring Boot cookbook · Advanced
Make POST endpoints idempotent in Spring Boot
Prevent duplicate orders or charges when clients retry a request after a timeout.
Approach
Store each Idempotency-Key per client in a table with a unique constraint, together with a hash of the request and the eventual response. Claim the key in its own transaction first; duplicates read the stored response (or get 409 while processing, 422 if the body differs).
Solution
@Service
class IdempotencyService {
private final IdempotencyRepository repo;
private final TransactionTemplate tx;
IdempotencyService(IdempotencyRepository repo, PlatformTransactionManager tm) {
this.repo = repo; this.tx = new TransactionTemplate(tm);
}
<T> ResponseEntity<T> execute(String clientId, String key, String requestHash, Class<T> type, Supplier<ResponseEntity<T>> work) {
try {
tx.executeWithoutResult(s -> repo.saveAndFlush(IdempotencyRecord.processing(clientId, key, requestHash)));
} catch (DataIntegrityViolationException duplicate) {
IdempotencyRecord existing = repo.findByClientIdAndKey(clientId, key).orElseThrow();
if (!existing.requestHash().equals(requestHash)) throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
if (existing.isProcessing()) throw new ResponseStatusException(HttpStatus.CONFLICT, "Request in progress");
return ResponseEntity.status(existing.status()).body(existing.bodyAs(type));
}
ResponseEntity<T> response = work.get();
tx.executeWithoutResult(s -> repo.complete(clientId, key, response.getStatusCode().value(), response.getBody()));
return response;
}
}
@PostMapping("/api/v1/orders")
ResponseEntity<OrderResponse> create(@RequestHeader("Idempotency-Key") @Size(min = 8, max = 100) String key,
@Valid @RequestBody CreateOrderRequest body, Authentication auth) {
return idempotency.execute(auth.getName(), key, hash(body), OrderResponse.class,
() -> ResponseEntity.status(HttpStatus.CREATED).body(orders.create(auth.getName(), body)));
}
Pitfalls
- Claiming the key inside the same transaction as the work (a rollback loses the claim and duplicates race).
- No expiry/cleanup of old keys.
- Not forwarding the key to payment providers.
Variations
- Redis
SET NXfor the claim with a TTL, database for the stored response.
Further reading: Stripe: Idempotent requests
Reference: https://docs.stripe.com/api/idempotent_requests
More Spring Boot recipes
- Add correlation IDs to Spring Boot logs
- Add retries and a circuit breaker with Resilience4j
- Authenticate machine clients with API keys in Spring Security
- Cache service results in Redis with @Cacheable
- Consume Kafka messages with retries and a dead-letter topic
- Handle concurrent updates with optimistic locking
- Implement soft delete with Hibernate
- Map entities to DTOs with MapStruct