POST Idempotency in Spring Boot
Fast recall
Quick revision
Core answer
Interview-ready guide
Interview answer
A retry-safe POST gives one logical operation a stable idempotency key. The client creates the key and sends it again only when retrying the same work. The server scopes it to the authenticated account or tenant, so two customers can use the same text without sharing an operation. The key is not authentication and must never let a caller access another account's result.
The service creates a canonical fingerprint from the request fields that define the operation. For POST /orders, that may include product, quantity, price agreement, and destination, but not unstable details such as JSON property order. Saving the fingerprint prevents key K7 from first meaning one order and later meaning different input. A mismatch is a client conflict, not a new operation.
Before creating the order, the service calls something like reserve(accountId, key, fingerprint) in one atomic database operation. A unique constraint, insert-on-conflict, or suitable lock allows only one owner. A separate find followed by insert is unsafe because two requests can both see no row. Spring's @Transactional helps define a local transaction, but the repository still must implement this atomic gate.
If two POST /orders calls arrive together with key K7, one wins the reservation and creates order 42. The other reads the same operation record instead of creating order 43. If order 42 is complete, it receives the stored status, selected headers, and response data. If processing continues, the API follows its documented wait-or-conflict response. Both calls still pass normal authentication and authorization.
The operation record and business data should commit consistently when they share one database. An in-memory map is not enough because it disappears on restart and differs across application instances. An external payment or message broker sits outside that local transaction, so the design needs its stable operation reference, an outbox, or reconciliation after an uncertain result. Expiry, failed results, and abandoned PROCESSING entries also need explicit recovery rules.
POST Idempotency in Spring Boot
Direct Answer
A Spring Boot POST can be made retry-safe by accepting an idempotency key, atomically reserving it with the authenticated caller and a request fingerprint, executing the business transaction once, and storing the final status, headers, and response. Retries replay that outcome; mismatched requests and in-progress keys follow explicit rules.
Deep Dive & Architecture
A naïve implementation performs “find key, then create order, then save key.” Two threads can both observe no key before either saves, causing duplicate work. The reservation must be atomic. A unique index on the scoped key, an insert with conflict handling, or a database lock makes exactly one request the owner.
The request fingerprint prevents accidental key reuse. It should cover the normalized fields that define the operation, not unstable transport details such as header order. Store only a hash if retaining the full request would expose sensitive data. Constant-time comparison is useful when a fingerprint becomes security-sensitive, although its main role here is integrity.
Crash behavior must be designed. If business data commits but the replay record does not, a retry may duplicate the effect. Keeping both in one local transaction works when they share a database. Across external payment providers, use their operation key, an outbox, or a reconciled state machine; a single annotation cannot create a distributed transaction.
A completed entry can replay the original outcome. An in-progress entry may return 409 Conflict, briefly wait, or use another clearly documented policy. Failures need a rule: deterministic validation failures can often be replayed, while transient failures might release or retain the key. Expiry must be longer than the promised retry window.
Code Example
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
final class OrderCommandService {
private final IdempotencyRepository keys;
private final OrderRepository orders;
private final RequestFingerprinter fingerprints;
OrderCommandService(IdempotencyRepository keys, OrderRepository orders,
RequestFingerprinter fingerprints) {
this.keys = keys;
this.orders = orders;
this.fingerprints = fingerprints;
}
@Transactional
OrderReply create(String accountId, String key, CreateOrder request) {
String fingerprint = fingerprints.sha256(request);
Reservation r = keys.reserve(accountId, key, fingerprint); // unique constraint
if (r.fingerprintMismatch()) throw new KeyReuseConflict();
if (r.completed()) return r.savedReply();
if (!r.ownedByThisCall()) throw new OperationInProgress();
OrderReply reply = orders.create(request);
keys.complete(accountId, key, fingerprint, reply);
return reply;
}
}
interface IdempotencyRepository {
Reservation reserve(String accountId, String key, String fingerprint);
void complete(String accountId, String key, String fingerprint, OrderReply reply);
}
interface OrderRepository { OrderReply create(CreateOrder request); }
interface RequestFingerprinter { String sha256(CreateOrder request); }
record CreateOrder(String productId, int quantity) {}
record OrderReply(long id) {}
record Reservation(boolean fingerprintMismatch, boolean completed,
boolean ownedByThisCall, OrderReply savedReply) {}
final class KeyReuseConflict extends RuntimeException {}
final class OperationInProgress extends RuntimeException {}
reserve must be implemented with a database-level atomic operation; a check followed by an ordinary insert is still racy. Real code must also define rollback and recovery when external systems participate.
Check your understanding
Answer these without looking back at the article.
- Why must idempotency-key reservation be atomic?
- What should happen when the same key arrives with different request content?
- How do external side effects change the transaction design?
Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.