Implementing a Production‑Ready Idempotency Middleware with Spring Boot and Redis

This article explains how to build a lightweight, production‑grade idempotency middleware for Spring Boot 3 using AOP, a custom @Idempotent annotation, SpEL‑generated keys, and Redis atomic operations, covering architecture, core components, code examples, use cases, and performance characteristics.

Java Companion
Java Companion
Java Companion
Implementing a Production‑Ready Idempotency Middleware with Spring Boot and Redis

Why Build This Middleware?

Repeated user actions such as clicking the "Submit Order" button multiple times, network timeout retries, MQ message redelivery, or candidates resubmitting enrollment information can generate duplicate orders, multiple payment callbacks, or duplicate identity records, violating the idempotency principle that the same operation should yield the same result regardless of how many times it is executed.

Existing Solutions and Their Drawbacks

Database unique index – only works for write scenarios and cannot prevent concurrent penetration.

Front‑end button disabling – unreliable and can be bypassed.

Token mechanism – requires front‑end and back‑end coordination, increasing complexity.

Manual Redis handling – repetitive code and high maintenance cost.

Therefore, the author chose to combine AOP, a custom annotation, and Redis to create a generic, lightweight, high‑performance idempotency middleware.

Overall Architecture

The entire process completes in milliseconds and imposes no pressure on the database.

Architecture diagram
Architecture diagram

Core Components

@Idempotent – custom annotation that declares idempotency rules.

IdempotentAspect – AOP aspect that intercepts methods annotated with @Idempotent.

SpelKeyGenerator – uses Spring SpEL to generate a unique key dynamically.

RedisIdempotentStore – implements atomic verification based on Redis.

IdempotentFailureHandler – customizable strategy for handling duplicate requests.

Implementation Details

Annotation Definition

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    String key();
    int expire() default 300;
    String value() default "1";
}

AOP Aspect Logic

@Aspect
public class IdempotentAspect {
    private final IdempotentService idempotentService;
    private final ExpressionParser parser = new SpelExpressionParser();
    private final StandardReflectionParameterNameDiscoverer discoverer = new StandardReflectionParameterNameDiscoverer();

    public IdempotentAspect(IdempotentService idempotentService) {
        this.idempotentService = idempotentService;
    }

    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String[] paramNames = discoverer.getParameterNames(signature.getMethod());
        Object[] args = joinPoint.getArgs();

        // Parse SpEL key
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < args.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        String key = parser.parseExpression(idempotent.key()).getValue(context, String.class);

        if (!idempotentService.tryLock(key, idempotent.expire())) {
            if (idempotent.mode() == Idempotent.Mode.REJECT) {
                throw new IllegalStateException("重复请求,请勿重复提交");
            }
            // TODO: RETURN_CACHE mode (needs result cache)
        }

        return joinPoint.proceed();
    }
}

Custom Failure Handler (Extensible)

public interface IdempotentFailureHandler {
    void handle(String key, Method method);
}

@Component
public class DefaultIdempotentFailureHandler implements IdempotentFailureHandler {
    @Override
    public void handle(String key, Method method) {
        // Default does nothing; AOP throws exception
    }
}

Usage Examples

Case 1: Order Submission (Prevent Duplicate Orders)

@PostMapping("/order")
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
public Result<String> createOrder(@RequestParam String userId, @RequestParam String goodsId) {
    // Simulate order logic
    orderService.create(userId, goodsId);
    return Result.success("下单成功");
}

If the same user attempts to order the same product within five minutes, subsequent requests are rejected.

Case 2: Candidate Enrollment (Prevent Duplicate ID Card)

@PostMapping("/enroll")
@Idempotent(key = "'enroll:' + #candidate.idCard", expire = 300)
public Result<Void> enroll(@RequestBody Candidate candidate) {
    // Prevent duplicate enrollment with the same ID card
    enrollmentService.save(candidate);
    return Result.OK();
}

public class Candidate {
    private String name;
    private String idCard;
    private String phone;
}

The generated key looks like enroll:11010119900307XXXX; the same key cannot be submitted again within five minutes.

Case 3: Seckill Scenario (User + Product Dimension)

@PostMapping("/seckill")
@Idempotent(key = "'seckill:' + #userId + ':' + #goodsId", expire = 60)
public Result<String> seckill(@RequestParam String userId, @RequestParam Long goodsId) {
    return seckillService.execute(userId, goodsId);
}

Even if a user clicks aggressively, only one effective request is allowed per minute.

Performance and Reliability

Performance: Redis SET NX EX is an atomic operation; a single node can handle QPS > 50 k.

Consistency: Based on Redis distributed‑lock semantics, naturally supports cluster deployment.

Security: Keys are generated by business logic; no injection risk because SpEL runs in a controlled context.

Resource Management: Keys expire automatically, eliminating memory‑leak concerns.

Dependency and Integration

<dependency>
    <groupId>io.github.songrongzhen</groupId>
    <artifactId>once-kit-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>

After adding the starter, simply annotate any endpoint that requires idempotency:

@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaAOPRedisSpring BootidempotencyAnnotation
Java Companion
Written by

Java Companion

A highly professional Java public account

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.