Making operations genuinely idempotent
Recording that you processed something is not enough when the side effect is elsewhere.
Open this lesson in the learning hubKey points
- A network timeout leaves the caller unable to tell whether the work happened. Idempotency is what makes the retry safe, and it is the caller who must supply the key.
- The key must be stable across retries and unique per intent. A client-generated UUID per logical operation works; a hash of the payload does not, because two genuinely distinct identical orders would collide.
- Store the key in the same transaction as the work. Checking a table and then doing the work in a separate transaction reintroduces the race the key was meant to close.
- Return the original response for a duplicate, not just a success. The caller retried because it never saw the first answer, so it still needs the payment id you generated.
- Recording your own processing does not undo an external side effect. The email already sent or the payment already taken needs its own idempotency key on that call.
- Keys need a retention policy. An idempotency table without expiry grows forever; a 24 or 48 hour window usually covers realistic retry behaviour.
Example
@Service
public class PaymentService {
// One transaction covers the check, the work, and the record of it.
@Transactional
public PaymentResult charge(String idempotencyKey, ChargeRequest req) {
// A duplicate must return the ORIGINAL response - the caller retried
// because it never saw it, and still needs the payment id.
Optional<IdempotencyRecord> seen = records.findByKey(idempotencyKey);
if (seen.isPresent()) {
return json.read(seen.get().response(), PaymentResult.class);
}
// The external call needs its OWN key - our table cannot un-charge a card.
PaymentResult result = gateway.charge(req, idempotencyKey);
records.save(new IdempotencyRecord(
idempotencyKey, json.write(result), Instant.now()));
return result;
}
}
/*
* CREATE TABLE idempotency_record (
* key VARCHAR(64) PRIMARY KEY, -- unique constraint IS the lock:
* response JSON NOT NULL, -- two concurrent inserts, one wins
* created_at TIMESTAMP NOT NULL,
* INDEX idx_created (created_at) -- for the expiry job
* );
*
* Two requests racing with the same key: one INSERT succeeds, the other
* hits the primary key, retries the read, and returns the stored response.
* The constraint does the mutual exclusion - no application lock needed.
*/
// HTTP contract, so callers know what to send:
//
// POST /payments
// Idempotency-Key: 6f1c2a9e-...
//
// 201 Created - processed now
// 200 OK - duplicate; this is the original result
// 409 Conflict - same key, DIFFERENT body: a client bug, not a retry
Store the key in the same transaction as the work, return the original response for duplicates, and give the external call its own key.
This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Microservices course, and every lesson in it is listed on the Microservices contents page.