Monday, 5 October 2026

Idempotency

 

The client sends the Idempotency-Key to the server. The server generates/stores the result associated with that key.

Flow

Client
   |
   | POST /payments
   | Idempotency-Key: abc-123-xyz
   | amount = ₹10,000
   ↓
Payment API
   |
   | Check: Have I already processed abc-123-xyz?
   |
   ├── NO → Process payment
   |        Save key + result
   |
   └── YES → Return previous result

First request

The client generates a unique key:

POST /payments
Idempotency-Key: abc-123-xyz
Content-Type: application/json
{
  "amount": 10000,
  "currency": "INR"
}

Server processes it:

abc-123-xyz → PAY-1001 → SUCCESS

The server stores something like:

idempotency_key = abc-123-xyz
payment_id      = PAY-1001
status          = SUCCESS

If network fails

The client doesn't know whether payment succeeded, so it sends the same key:

POST /payments
Idempotency-Key: abc-123-xyz

Server checks:

abc-123-xyz exists?
        ↓
      YES
        ↓
Don't process payment again
        ↓
Return PAY-1001

So:

Request 1 → abc-123-xyz → Payment PAY-1001 created
Request 2 → abc-123-xyz → Existing PAY-1001 returned
Request 3 → abc-123-xyz → Existing PAY-1001 returned

Important point

Client generates the key; server is responsible for enforcing it.

Usually the client generates a UUID:

550e8400-e29b-41d4-a716-446655440000

For example, in a frontend:

const idempotencyKey = crypto.randomUUID();

fetch("/payments", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey
    },
    body: JSON.stringify({
        amount: 10000,
        currency: "INR"
    })
});

The same key must be reused when retrying the same logical payment. A new key means:

new key → server assumes new operation

This is why you should not generate a new key for every retry.

show how backend accept validate and return same key if fail, if pass does it return empty

Yes. The backend accepts the key from the client, validates it, checks whether it was already processed, and then returns the payment result.

It does not normally return an empty response just because the key is valid.

Typical Spring Boot flow

Client
  |
  | POST /payments
  | Idempotency-Key: abc-123
  | {amount: 10000}
  ↓
Controller
  |
  | 1. Validate key
  | 2. Check DB
  ↓
┌─────────────────────────────┐
│ Does abc-123 already exist? │
└─────────────────────────────┘
       |              |
      YES             NO
       |              |
       ↓              ↓
Return saved       Process payment
response            |
                    ↓
                Save key + result
                    |
                    ↓
                Return result

Example Controller

@PostMapping("/payments")
public ResponseEntity<PaymentResponse> createPayment(
        @RequestHeader("Idempotency-Key") String idempotencyKey,
        @RequestBody PaymentRequest request) {

    // 1. Validate key
    if (idempotencyKey == null || idempotencyKey.isBlank()) {
        return ResponseEntity.badRequest().build();
    }

    // 2. Check whether this key was already processed
    Optional<IdempotencyRecord> existing =
            idempotencyRepository.findByKey(idempotencyKey);

    if (existing.isPresent()) {
        // Retry → return SAME result
        return ResponseEntity.ok(existing.get().getPaymentResponse());
    }

    // 3. First request → process payment
    PaymentResponse response = paymentService.processPayment(request);

    // 4. Store key + result
    idempotencyRepository.save(
        new IdempotencyRecord(
            idempotencyKey,
            response
        )
    );

    // 5. Return result
    return ResponseEntity.ok(response);
}

First request

Client sends:

POST /payments
Idempotency-Key: abc-123

Backend:

abc-123 exists? → NO
       ↓
Process payment
       ↓
PAY-1001 created
       ↓
Save:
abc-123 → PAY-1001 SUCCESS
       ↓
Return:
{
  "paymentId": "PAY-1001",
  "status": "SUCCESS"
}

Retry because client didn't receive response

Client sends the same key:

POST /payments
Idempotency-Key: abc-123

Backend:

abc-123 exists? → YES
       ↓
DO NOT process payment again
       ↓
Return stored response
{
  "paymentId": "PAY-1001",
  "status": "SUCCESS"
}

So the client receives the same payment result.


What if the key is valid but payment fails?

Suppose:

abc-123 → Payment processing failed

You have a design choice.

For many payment systems, you store the final result associated with the key, including an appropriate failure outcome, so retries with the same key don't accidentally create a different operation.

For example:

{
  "paymentId": "PAY-1001",
  "status": "FAILED",
  "reason": "Insufficient funds"
}

A retry using abc-123 can return that same result.


One very important backend detail

You need a unique constraint on the idempotency key:

CREATE TABLE idempotency_keys (
    id BIGINT PRIMARY KEY,
    idempotency_key VARCHAR(255) UNIQUE,
    payment_id VARCHAR(100),
    status VARCHAR(50),
    response JSON
);

The UNIQUE constraint is important because two requests could arrive at almost exactly the same time:

Request A ──→ abc-123 ──→ check DB → not found
Request B ──→ abc-123 ──→ check DB → not found

Without proper concurrency handling, both could process the payment.

That's where real-world idempotency becomes more interesting: you need atomic insert / unique constraint + transaction/locking strategy, not just an if (exists) check.

No. @Idempotent is not a built-in Spring Boot annotation.

If you see code like:

@Idempotent
@PostMapping("/payments")
public PaymentResponse createPayment(...) {
    ...
}

then @Idempotent is almost certainly:

  • a custom annotation created by the application, or
  • provided by a third-party library.

Spring Boot itself does not provide a general-purpose @Idempotent annotation for REST APIs.

In Spring Boot

You could create your own:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
}

Then use it:

@Idempotent
@PostMapping("/payments")
public PaymentResponse createPayment(...) {
    // payment logic
}

But the annotation alone does nothing.

You would need something such as Spring AOP:

@Idempotent
     ↓
Spring AOP intercepts method
     ↓
Read Idempotency-Key
     ↓
Check Redis/DB
     ↓
Already exists?
   YES → return previous response
   NO  → execute method
          ↓
       save response

So if you see @Idempotent in a project, don't assume it's Spring Boot. Check its import:

import ....Idempotent;

That import will tell you exactly where it comes from.

@Idempotent create realtime AOP where i use response store and request validation

Yes. Let's build a real Spring Boot AOP-based idempotency implementation where:

  1. Client sends Idempotency-Key
  2. AOP intercepts methods marked @Idempotent
  3. Request/key is validated
  4. Redis checks whether the key was already processed
  5. If already processed → return the same stored response
  6. If not → execute the actual business method
  7. Store the response in Redis
  8. Return the response

For a real application, I would use Redis, because it is fast and supports atomic operations.


1. Architecture

                 Client
                   |
                   | POST /payments
                   | Idempotency-Key: abc-123
                   ↓
        ┌──────────────────────┐
        │ Spring Controller    │
        │ @Idempotent          │
        └──────────┬───────────┘
                   |
                   ↓
        ┌──────────────────────┐
        │     AOP Aspect       │
        │                      │
        │ Validate key         │
        │ Check Redis          │
        └──────────┬───────────┘
                   |
          ┌────────┴────────┐
          ↓                 ↓
      Key exists         Key doesn't exist
          |                 |
          ↓                 ↓
   Return stored       Execute method
      response              |
                            ↓
                     Payment Service
                            |
                            ↓
                         Database
                            |
                            ↓
                     PaymentResponse
                            |
                            ↓
                      Store in Redis
                            |
                            ↓
                       Return response

2. Maven dependencies

For Spring Boot 3/4 style applications, conceptually you need:

<dependencies>

    <!-- Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- AOP -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>

    <!-- Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

</dependencies>

3. Create @Idempotent

This is our custom annotation.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /**
     * How long the response should remain cached.
     */
    long ttlSeconds() default 86400;
}

Here:

@Idempotent

means:

"Apply idempotency handling to this API."


4. Request DTO

public record PaymentRequest(
        BigDecimal amount,
        String currency,
        String customerId
) {
}

Example request:

{
  "amount": 10000,
  "currency": "INR",
  "customerId": "CUST-101"
}

5. Response DTO

public record PaymentResponse(
        String paymentId,
        String status,
        BigDecimal amount
) {
}

Example:

{
  "paymentId": "PAY-1001",
  "status": "SUCCESS",
  "amount": 10000
}

6. Redis configuration

spring:
  data:
    redis:
      host: localhost
      port: 6379

Start Redis locally:

docker run --name redis \
  -p 6379:6379 \
  -d redis

7. Idempotency response object

We need to store more than just the response.

public record IdempotencyRecord(
        String status,
        String response
) {
}

For example Redis could contain:

idempotency:abc-123

with:

{
  "status": "COMPLETED",
  "response": "{\"paymentId\":\"PAY-1001\",\"status\":\"SUCCESS\"}"
}

8. Redis service

@Service
public class IdempotencyStore {

    private final RedisTemplate<String, String> redisTemplate;

    public IdempotencyStore(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean createProcessingKey(
            String key,
            long ttlSeconds) {

        Boolean created = redisTemplate
                .opsForValue()
                .setIfAbsent(
                        key,
                        "PROCESSING",
                        Duration.ofSeconds(ttlSeconds)
                );

        return Boolean.TRUE.equals(created);
    }

    public void saveResponse(
            String key,
            String response,
            long ttlSeconds) {

        redisTemplate.opsForValue().set(
                key,
                response,
                Duration.ofSeconds(ttlSeconds)
        );
    }
}

The important part is:

setIfAbsent(...)

This is effectively:

SET key only if it doesn't already exist

That is important for concurrent requests.


9. Now the important part — AOP

@Aspect
@Component
public class IdempotencyAspect {

    private final IdempotencyStore store;
    private final ObjectMapper objectMapper;

    public IdempotencyAspect(
            IdempotencyStore store,
            ObjectMapper objectMapper) {

        this.store = store;
        this.objectMapper = objectMapper;
    }

    @Around("@annotation(idempotent)")
    public Object handleIdempotency(
            ProceedingJoinPoint joinPoint,
            Idempotent idempotent) throws Throwable {

        HttpServletRequest request =
                ((ServletRequestAttributes)
                        RequestContextHolder
                                .currentRequestAttributes())
                        .getRequest();

        String idempotencyKey =
                request.getHeader("Idempotency-Key");

        // 1. Validate key
        if (idempotencyKey == null ||
                idempotencyKey.isBlank()) {

            throw new IllegalArgumentException(
                    "Idempotency-Key header is required");
        }

        // 2. Build Redis key
        String redisKey =
                "idempotency:" + idempotencyKey;

        // 3. Check existing key
        String existing =
                store.get(redisKey);

        if (existing != null) {

            // Request already processed
            if (!existing.equals("PROCESSING")) {

                return objectMapper.readValue(
                        existing,
                        PaymentResponse.class
                );
            }

            // Another request is currently processing
            throw new IllegalStateException(
                    "Request is already being processed");
        }

        // 4. Atomically create processing key
        boolean acquired =
                store.createProcessingKey(
                        redisKey,
                        idempotent.ttlSeconds()
                );

        if (!acquired) {

            throw new IllegalStateException(
                    "Request is already being processed");
        }

        try {

            // 5. Execute actual controller/service method
            Object response =
                    joinPoint.proceed();

            // 6. Serialize response
            String responseJson =
                    objectMapper.writeValueAsString(response);

            // 7. Store final response
            store.saveResponse(
                    redisKey,
                    responseJson,
                    idempotent.ttlSeconds()
            );

            // 8. Return response
            return response;

        } catch (Exception e) {

            /*
             * Important:
             * Remove PROCESSING key so that a retry
             * can attempt the operation again.
             */
            redisTemplate.delete(redisKey);

            throw e;
        }
    }
}

There is one correction here: the aspect would need the RedisTemplate if it directly deletes the key. Better design is to put delete() inside IdempotencyStore:

public void delete(String key) {
    redisTemplate.delete(key);
}

Then:

catch (Exception e) {
    store.delete(redisKey);
    throw e;
}

10. Controller

Now the controller becomes very clean.

@RestController
@RequestMapping("/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(
            PaymentService paymentService) {

        this.paymentService = paymentService;
    }

    @Idempotent
    @PostMapping
    public PaymentResponse createPayment(
            @RequestBody PaymentRequest request) {

        return paymentService.createPayment(request);
    }
}

The developer doesn't need to write:

if (keyExists) ...

inside every controller.

AOP handles it.


11. Payment service

@Service
public class PaymentService {

    public PaymentResponse createPayment(
            PaymentRequest request) {

        System.out.println("PROCESSING PAYMENT");

        // Call payment provider
        // Save payment to database
        // etc.

        return new PaymentResponse(
                "PAY-1001",
                "SUCCESS",
                request.amount()
        );
    }
}

12. First request

Client:

POST /payments
Idempotency-Key: abc-123
Content-Type: application/json
{
  "amount": 10000,
  "currency": "INR",
  "customerId": "CUST-101"
}

AOP:

abc-123
   ↓
Redis lookup
   ↓
NOT FOUND
   ↓
SETNX abc-123 = PROCESSING
   ↓
joinPoint.proceed()
   ↓
PaymentService
   ↓
PAY-1001 SUCCESS
   ↓
Store response

Redis:

idempotency:abc-123

{
    "paymentId": "PAY-1001",
    "status": "SUCCESS",
    "amount": 10000
}

Client gets:

{
  "paymentId": "PAY-1001",
  "status": "SUCCESS",
  "amount": 10000
}

13. Client retries

Suppose response was lost:

Backend → Payment successful
Backend → Response
       X
    Network failure

Client doesn't know what happened.

It retries:

POST /payments
Idempotency-Key: abc-123

AOP:

abc-123
   ↓
Redis
   ↓
FOUND
   ↓
Return stored response

Payment service is not called.

Client gets:

{
  "paymentId": "PAY-1001",
  "status": "SUCCESS",
  "amount": 10000
}

Therefore:

Payment created: 1
API requests:    2

14. What if the key is missing?

Client:

POST /payments

No:

Idempotency-Key

AOP rejects it:

400 Bad Request

Response could be:

{
  "error": "Idempotency-Key header is required"
}

15. What if the key is already processing?

This situation is important.

Imagine two requests arrive simultaneously:

Request A ──→ abc-123
Request B ──→ abc-123

A:

SETNX abc-123
     ↓
SUCCESS
     ↓
PROCESSING

B:

SETNX abc-123
     ↓
FAIL
     ↓
Already processing

So B should not execute the payment.

Depending on your API design, you might return:

409 Conflict

or:

409 Request already in progress

16. One more VERY important validation

You shouldn't only validate:

Idempotency-Key exists

You should also make sure the same key isn't reused for a different request.

For example:

First request

Idempotency-Key: abc-123
{
    "amount": 10000
}

Then somebody sends:

Idempotency-Key: abc-123
{
    "amount": 50000
}

That's suspicious.

The server should detect:

Same key
     +
Different request
     ↓
400 Bad Request

A common solution is to store a request fingerprint/hash:

idempotency_key
request_hash
status
response

For example:

abc-123
SHA256(request)
SUCCESS
PAY-1001 response

On retry:

abc-123
SHA256(new request)

If hashes don't match:

409/400
Idempotency key reused with different request

17. Production architecture I'd recommend

For your Spring Boot/RAG/backend experience, understand this architecture:

                 Client
                    |
                    | Idempotency-Key
                    ↓
              API Gateway
                    |
                    ↓
            Spring Boot API
                    |
             @Idempotent
                    |
                    ↓
               AOP Aspect
                    |
          ┌─────────┴─────────┐
          ↓                   ↓
       Redis                Database
          |                   |
          |              Business data
          |
          ├── PROCESSING
          |
          └── COMPLETED
                 +
              response

And the key production concepts are:

Request validation

Key exists?
Key format valid?
Same request hash?

Concurrency

SETNX / atomic operation

Response storage

key → status + response

Failure handling

PROCESSING → failure
             ↓
       remove/recover key

Expiration

TTL → don't keep idempotency records forever

Database consistency

For something like payment, Redis idempotency alone isn't enough. The payment database/provider operation and idempotency record need a carefully designed consistency strategy. Otherwise you can still have edge cases around crashes between "payment succeeded" and "response stored."

So the simple AOP implementation is excellent for understanding the concept, but payments require a stronger production design than just caching the controller response in Redis.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /**
     * How long the response should remain cached.
     */
    long ttlSeconds() default 86400;
}

vs 


@Aspect
@Component
public class IdempotencyAspect {

They are two completely different things. This distinction is very important when learning Spring AOP.

Think of it as:

@Idempotent annotation
        ↓
      LABEL
        ↓
"Which methods should have idempotency?"
        
        +

@Aspect class
        ↓
     LOGIC
        ↓
"What should happen when that method is called?"

1. @Idempotent — custom annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    long ttlSeconds() default 86400;
}

This does not implement idempotency.

It simply creates your own annotation that you can put on a method:

@Idempotent
@PostMapping("/payments")
public PaymentResponse createPayment() {
    ...
}

Think of it like putting a sticker on the method:

createPayment()
      ↑
   @Idempotent

The sticker says:

"This method needs idempotency handling."

But the sticker itself doesn't do anything.


2. @Aspect — actual logic

@Aspect
@Component
public class IdempotencyAspect {

This class contains the actual idempotency logic.

For example:

@Around("@annotation(idempotent)")
public Object handleIdempotency(
        ProceedingJoinPoint joinPoint,
        Idempotent idempotent) {

    // Read Idempotency-Key
    // Check Redis
    // If exists → return previous response
    // If doesn't exist → execute method
    // Save response

}

The important part is:

@Around("@annotation(idempotent)")

It means:

"Whenever a method has @Idempotent, intercept that method."


3. How they work together

Suppose you have:

@Idempotent
@PostMapping("/payments")
public PaymentResponse createPayment(
        @RequestBody PaymentRequest request) {

    return paymentService.createPayment(request);
}

Spring sees:

             createPayment()
                    ↑
              @Idempotent
                    |
                    ↓
       IdempotencyAspect finds it
                    |
                    ↓
          handleIdempotency()
                    |
        ┌───────────┴───────────┐
        ↓                       ↓
    Key exists?             Key doesn't exist
        ↓                       ↓
 Return saved response    Execute method
                                ↓
                         createPayment()
                                ↓
                         Save response
                                ↓
                         Return response

4. Very simple analogy

Imagine a security guard.

Annotation

@Idempotent

is like putting a sign on a door:

┌───────────────────────┐
│       PAYMENT API     │
│                       │
│  🔒 Idempotent        │
└───────────────────────┘

It tells the system:

"This door needs special handling."

Aspect

@Aspect
public class IdempotencyAspect

is the security guard standing outside the door.

The guard actually checks:

Who is coming?
   ↓
What's the key?
   ↓
Have I seen this key?
   ↓
YES → give previous response
NO  → allow request through

5. Why not put everything inside @Idempotent?

You might wonder:

Why don't we just put the logic inside the annotation?

Because an annotation is primarily metadata.

This:

public @interface Idempotent {
    long ttlSeconds() default 86400;
}

basically says:

Name: Idempotent
Configuration: ttlSeconds = 86400

It doesn't contain the business behavior.

The Aspect contains the behavior.


6. The ttlSeconds connects them

This part is particularly useful:

@Idempotent(ttlSeconds = 3600)

The annotation can provide configuration to the Aspect.

The Aspect receives it here:

@Around("@annotation(idempotent)")
public Object handleIdempotency(
        ProceedingJoinPoint joinPoint,
        Idempotent idempotent) {

Then:

idempotent.ttlSeconds()

returns:

3600

So:

@Idempotent(ttlSeconds = 3600)

means:

Store the idempotency result for 1 hour.

While:

@Idempotent

means:

Use the default 86400 seconds = 24 hours.


7. Complete relationship

// 1. Define the annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    long ttlSeconds() default 86400;
}

↓

// 2. Implement the behavior
@Aspect
@Component
public class IdempotencyAspect {

    @Around("@annotation(idempotent)")
    public Object handle(
            ProceedingJoinPoint joinPoint,
            Idempotent idempotent) {

        long ttl =
            idempotent.ttlSeconds();

        // idempotency logic...

        return ...;
    }
}

↓

// 3. Developer uses it
@Idempotent(ttlSeconds = 3600)
@PostMapping("/payments")
public PaymentResponse createPayment(
        @RequestBody PaymentRequest request) {

    return paymentService.createPayment(request);
}

So the relationship is:

┌────────────────────┐
│   @Idempotent      │
│                    │
│ Metadata/config    │
│ ttlSeconds = 3600  │
└─────────┬──────────┘
          │
          │ Aspect detects annotation
          ↓
┌────────────────────┐
│ IdempotencyAspect  │
│                    │
│ Actual logic       │
│                    │
│ Read key           │
│ Validate            │
│ Check Redis        │
│ Execute method     │
│ Store response     │
└────────────────────┘

One-line interview answer

@Idempotent is a custom marker/configuration annotation that identifies which methods require idempotency, while @Aspect contains the cross-cutting logic that intercepts those methods and actually implements idempotency.