Implementing API Idempotency Checks with Design Patterns in Java
This article explains the concept of idempotency, why it is essential for API reliability, and demonstrates four practical idempotency strategies—including database primary keys, optimistic locking, token validation, and Redis caching—implemented via custom annotations and the Strategy pattern in a Spring‑Boot Java project.
In recent weeks the author, a student, decided to explore idempotency after noticing various articles on the topic and wanted to apply design patterns to make the implementation flexible.
What is Idempotency? In mathematics, a function f is idempotent if f(f(x)) = f(x). In programming, a method or API is idempotent when multiple identical calls produce the same effect as a single call. The article gives a natural idempotent example (a setter that always writes the same value) and a non‑idempotent example (an increment method).
Why implement idempotency? Repeated submissions can occur due to front‑end double clicks, malicious repeated actions, client‑side timeout retries, or message‑queue duplicate consumption. Idempotency prevents inconsistent state and unexpected side effects.
Common HTTP methods idempotency table
Method
Idempotent
Description
GET
✓
Retrieves resources without modifying them.
POST
✗
Creates new resources; each call adds data.
PUT
_
May be idempotent when updating by a unique key; not when performing accumulative updates.
DELETE
_
Idempotent when deleting by a unique identifier; not guaranteed when using conditional deletes.
Four idempotency solutions
1. Database unique primary key : Use a globally unique ID (e.g., a distributed ID) as the primary key for insert operations. If the key already exists, the database throws a duplicate‑key exception, indicating a repeated request.
2. Database optimistic lock : Add a version field to the table and include it in the WHERE clause of update statements. The update succeeds only if the version matches, ensuring only one successful update.
3. Anti‑repeat token : The client obtains a token (e.g., from the server) and includes it in the request header. The server stores the token in Redis; if the token already exists, the request is rejected.
4. Redis key : Generate a unique key (e.g., MD5 of class name, method name, arguments, and token) and store a marker in Redis with an expiration. Subsequent identical keys within the TTL are considered duplicates.
Combining the approaches with annotations and the Strategy pattern
The author defines a custom annotation @RequestMany that specifies the idempotency strategy and optional expiration time. An Aspect intercepts methods annotated with @RequestMany, extracts the strategy name, generates a unique key, and delegates validation to the appropriate RequestManyStrategy implementation retrieved from the Spring context.
Code snippets
Custom annotation:
package org.example.annotation;
import java.lang.annotation.*;
/**
* @author zrq
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface RequestMany {
/** Strategy name */
String value() default "";
/** Expiration time (minutes) */
long expireTime() default 0;
}Aspect for validation:
package org.example.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.example.annotation.RequestMany;
import org.example.factory.RequestManyStrategy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
@Aspect
@Component
public class RequestManyValidationAspect {
@Autowired
private Map<String, RequestManyStrategy> idempotentStrategies;
@Around("@annotation(org.example.annotation.RequestMany)")
public Object validateIdempotent(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
RequestMany requestMany = method.getAnnotation(RequestMany.class);
String strategy = requestMany.value();
Integer time = (int) requestMany.expireTime();
if (!idempotentStrategies.containsKey(strategy)) {
throw new IllegalArgumentException("Invalid idempotent strategy: " + strategy);
}
String key = generateKey(joinPoint);
RequestManyStrategy idempotentStrategy = idempotentStrategies.get(strategy);
idempotentStrategy.validate(key, time);
return joinPoint.proceed();
}
private String generateKey(ProceedingJoinPoint joinPoint) {
String className = joinPoint.getTarget().getClass().getSimpleName();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String methodName = methodSignature.getMethod().getName();
Object[] args = joinPoint.getArgs();
String argString = Arrays.stream(args).map(Object::toString).collect(Collectors.joining(","));
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("token");
String rawKey = className + ":" + methodName + ":" + argString + ":" + token;
return DigestUtils.md5DigestAsHex(rawKey.getBytes());
}
}Exception class:
package org.example.exception;
/** Runtime exception for idempotency validation failures */
public class RequestManyValidationException extends RuntimeException {
public RequestManyValidationException() {}
public RequestManyValidationException(String message) { super(message); }
public RequestManyValidationException(String message, Throwable cause) { super(message, cause); }
public RequestManyValidationException(Throwable cause) { super(cause); }
public RequestManyValidationException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}Strategy interface:
package org.example.factory;
import org.example.exception.RequestManyValidationException;
public interface RequestManyStrategy {
void validate(String key, Integer time) throws RequestManyValidationException;
}Redis implementation (strategy 4):
package org.example.factory.impl;
import org.example.exception.RequestManyValidationException;
import org.example.factory.RequestManyStrategy;
import org.example.utils.RedisCache;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Component
public class RedisIdempotentStrategy implements RequestManyStrategy {
@Resource
private RedisCache redisCache;
@Override
public void validate(String key, Integer time) throws RequestManyValidationException {
if (redisCache.hasKey(key)) {
throw new RequestManyValidationException("Too many requests");
} else {
redisCache.setCacheObject(key, "1", time, TimeUnit.MINUTES);
}
}
}Token implementation (strategy 3):
package org.example.factory.impl;
import org.example.exception.RequestManyValidationException;
import org.example.factory.RequestManyStrategy;
import org.example.utils.RedisCache;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@Component
public class TokenIdempotentStrategy implements RequestManyStrategy {
@Resource
private RedisCache redisCache;
@Override
public void validate(String key, Integer time) throws RequestManyValidationException {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String token = request.getHeader("token");
if (token == null || token.isEmpty()) {
throw new RequestManyValidationException("Unauthorized token");
}
boolean isDuplicate = performTokenValidation(key, token);
if (!isDuplicate) {
throw new RequestManyValidationException("Repeated request");
}
}
private boolean performTokenValidation(String key, String token) {
String storedToken = redisCache.getCacheObject(key);
return token.equals(storedToken);
}
}Utility class RedisCache provides generic methods for setting, getting, and expiring keys, as well as operations for lists, sets, maps, and bitmaps.
Configuration files (YAML/Properties) can map strategy names to bean IDs, allowing developers to switch strategies by changing the annotation value or external configuration.
Conclusion : By leveraging custom annotations, Spring AOP, and the Strategy pattern, developers can flexibly apply different idempotency mechanisms (DB primary key, optimistic lock, token, Redis) to any API endpoint, ensuring safe retries and preventing duplicate processing.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect
Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
