Contents

Clients generate a unique Idempotency-Key (UUID) for each logical operation and include it in the request header. If the server has already processed a request with that key, it returns the stored response instead of processing it again.

// POST /api/payments with header Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 @RestController @RequestMapping("/api/payments") public class PaymentController { private final PaymentService paymentService; private final IdempotencyStore idempotencyStore; @PostMapping public ResponseEntity<PaymentResponse> createPayment( @RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest req) { // 1. Check if already processed return idempotencyStore.get(idempotencyKey, PaymentResponse.class) .map(cached -> ResponseEntity.ok(cached)) // replay cached response .orElseGet(() -> { // 2. Process for the first time PaymentResponse response = paymentService.charge(req); // 3. Store result with TTL idempotencyStore.put(idempotencyKey, response, Duration.ofHours(24)); return ResponseEntity.status(HttpStatus.CREATED).body(response); }); } }

Use Redis for fast, TTL-aware key storage, or a database table for durability.

import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.time.Duration; import java.util.Optional; @Component public class RedisIdempotencyStore { private final StringRedisTemplate redis; private final ObjectMapper mapper; private static final String PREFIX = "idem:"; public <T> Optional<T> get(String key, Class<T> type) { String json = redis.opsForValue().get(PREFIX + key); if (json == null) return Optional.empty(); try { return Optional.of(mapper.readValue(json, type)); } catch (Exception e) { return Optional.empty(); } } public void put(String key, Object value, Duration ttl) { try { redis.opsForValue().set(PREFIX + key, mapper.writeValueAsString(value), ttl); } catch (Exception e) { throw new RuntimeException("Failed to store idempotency key", e); } } } -- Database alternative — durable but slower CREATE TABLE idempotency_keys ( key VARCHAR(100) PRIMARY KEY, response JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_idem_expires ON idempotency_keys(expires_at);

The simplest form of idempotency: let the database enforce uniqueness. A duplicate insert throws a DataIntegrityViolationException which you catch and convert to the stored result.

@Service public class OrderService { private final OrderRepository orderRepo; @Transactional public Order placeOrder(PlaceOrderRequest req) { // If the same (customerId, externalOrderId) already exists, return it return orderRepo.findByExternalOrderId(req.externalOrderId()) .orElseGet(() -> { try { Order order = new Order(req.customerId(), req.externalOrderId(), req.amount(), OrderStatus.PENDING); return orderRepo.save(order); } catch (DataIntegrityViolationException e) { // Race condition — another thread just inserted; fetch and return it return orderRepo.findByExternalOrderId(req.externalOrderId()) .orElseThrow(); } }); } } ALTER TABLE orders ADD CONSTRAINT uq_orders_external_id UNIQUE (customer_id, external_order_id);

Kafka's at-least-once delivery means your consumer may receive duplicate messages. Track processed event IDs to skip duplicates.

@Component public class PaymentEventConsumer { private final ProcessedEventRepository processedRepo; private final PaymentService paymentService; @KafkaListener(topics = "payment.requested", groupId = "payment-service") @Transactional public void consume(PaymentRequestedEvent event, @Header(KafkaHeaders.OFFSET) long offset, @Header(KafkaHeaders.RECEIVED_PARTITION) int partition) { String eventId = event.eventId(); // unique ID in the event payload // Skip if already processed if (processedRepo.existsById(eventId)) { log.debug("Skipping duplicate event id={}", eventId); return; } // Process paymentService.charge(event.orderId(), event.amount()); // Record as processed — same transaction as the business operation processedRepo.save(new ProcessedEvent(eventId, Instant.now())); } } CREATE TABLE processed_events ( event_id VARCHAR(100) PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Clean up old entries periodically DELETE FROM processed_events WHERE processed_at < NOW() - INTERVAL '7 days'; Save the processed event record in the same database transaction as the business operation. If the business operation rolls back, so does the deduplication record — preventing a scenario where the event is marked "processed" but the work wasn't actually done.

Many operations have natural keys that make them idempotent without extra infrastructure:

OperationNatural Idempotency Key
Create order(customerId, cartId) or externalOrderId
Send email(userId, emailTemplateId, date)
Reserve inventory(orderId, productId)
Kafka eventeventId field in the payload (UUID generated by producer)
Webhook deliveryWebhook provider's delivery ID in the header

Extract idempotency key checking into a reusable interceptor so controllers stay clean.

import jakarta.servlet.http.*; import org.springframework.web.servlet.HandlerInterceptor; @Component public class IdempotencyInterceptor implements HandlerInterceptor { private final RedisIdempotencyStore store; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!request.getMethod().equals("POST")) return true; String key = request.getHeader("Idempotency-Key"); if (key == null || key.isBlank()) return true; // optional — skip if not provided store.get(key, String.class).ifPresent(cached -> { try { response.setStatus(200); response.setContentType("application/json"); response.getWriter().write(cached); } catch (Exception e) { throw new RuntimeException(e); } }); // Store key in request attribute for the controller to save the response request.setAttribute("idempotencyKey", key); return true; } }