java
52 lines · 9 steps
How an idempotency aspect works in Spring
A Spring AOP aspect caches responses by Idempotency-Key so retried requests replay the same result instead of running twice.
Explained by
highlit
1@Aspect
2@Component
3public class IdempotencyAspect {
4
5 private static final String CACHE_NAME = "idempotencyKeys";
6
7 private final CacheManager cacheManager;
8 private final HttpServletRequest request;
9 private final ObjectMapper objectMapper;
10
11 public IdempotencyAspect(CacheManager cacheManager,
12 HttpServletRequest request,
13 ObjectMapper objectMapper) {
14 this.cacheManager = cacheManager;
15 this.request = request;
16 this.objectMapper = objectMapper;
17 }
18
19 @Around("@annotation(idempotent)")
20 public Object enforce(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
21 String key = request.getHeader("Idempotency-Key");
22 if (key == null || key.isBlank()) {
23 throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing Idempotency-Key header");
24 }
25
26 Cache cache = cacheManager.getCache(CACHE_NAME);
27 String cacheKey = idempotent.scope() + ":" + key;
28
29 Cache.ValueWrapper cached = cache.get(cacheKey);
30 if (cached != null) {
31 CachedResponse stored = objectMapper.convertValue(cached.get(), CachedResponse.class);
32 return ResponseEntity
33 .status(stored.status())
34 .header("Idempotent-Replayed", "true")
35 .body(stored.body());
36 }
37
38 if (cache.putIfAbsent(cacheKey, CachedResponse.pending()) != null) {
39 throw new ResponseStatusException(HttpStatus.CONFLICT, "Request with this key is already in progress");
40 }
41
42 try {
43 Object result = pjp.proceed();
44 ResponseEntity<?> response = (ResponseEntity<?>) result;
45 cache.put(cacheKey, new CachedResponse(response.getStatusCode().value(), response.getBody()));
46 return result;
47 } catch (Throwable ex) {
48 cache.evict(cacheKey);
49 throw ex;
50 }
51 }
52}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Idempotency keys let clients safely retry requests without triggering duplicate side effects.
- 2A pending-marker written with putIfAbsent turns a cache into a concurrency guard against in-flight duplicates.
- 3Evicting the key on failure keeps failed attempts retryable rather than permanently cached.
Related explainers
java
@Component @Converter public class EncryptedStringConverter implements AttributeConverter<String, String> {
Transparent column encryption in Spring & JPA
encryption
aes-gcm
jpa-converter
Advanced
10 steps
java
package com.acme.billing.config; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties;
Feature-flagged beans with Spring @ConditionalOnProperty
feature-flags
conditional-beans
strategy-pattern
Intermediate
5 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
php
<?php namespace App\Services;
Building a cached daily leaderboard in Laravel
caching
aggregation
eager-loading
Intermediate
9 steps
java
public static Map<String, String> parseCookieHeader(String header) { Map<String, String> cookies = new LinkedHashMap<>(); if (header == null || header.isBlank()) { return cookies;
Parsing an HTTP Cookie header in Java
string-parsing
http
url-decoding
Intermediate
6 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/how-an-idempotency-aspect-works-in-spring-explained-java-72ec/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.