Making operations genuinely idempotent

Microservices · lesson 30 of 33 · 6 min read

Recording that you processed something is not enough when the side effect is elsewhere.

Open this lesson in the learning hub

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