In financial systems, executing the same request twice (like a payment) can be catastrophic. A user might:
- Get charged twice
- See double entries
- Or even worse, lose trust in your system
To avoid this, APIs (especially for payments) often support idempotency keys. But not all backends or platforms provide built-in support. So I built one from scratch for my Kotlin backend.
🔁 What is Idempotency?
In software engineering, idempotency means that performing the same operation multiple times produces the same result as doing it once.
In simpler terms:
If you submit the same request twice, only one result should be processed.
💡 Real-world analogy
Imagine clicking a “Pay Now” button.
You click once — nothing happens — so you click again.
Without idempotency, you might be charged twice.
With idempotency, only one charge goes through — even if the button was clicked multiple times.
🛠️ In APIs
Many APIs (especially payment gateways) accept an idempotency key, these can be client-generated or server-stored unique identifiers for each operation. If the same key is used again, the server will:
- Return the original response, or
- Reject the duplicate, depending on the design
This makes APIs safe and reliable under:
- Network retries
- UI double-clicks
- Unexpected client behavior
🛠️ The Kotlin Implementation
Here’s the full utility class:
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
class IdempotencyUtil {
private val store = ConcurrentHashMap<String, Long>() // referenceNo -> expiryTimestamp
private lateinit var cleaner: ScheduledExecutorService
private val ttlMillis = TimeUnit.MINUTES.toMillis(5) // 5-minute TTL
fun isDuplicate(referenceNo: String): Boolean {
val now = Instant.now().toEpochMilli()
val expiry = store[referenceNo]
return if (expiry != null && expiry > now) {
true // Duplicate within TTL
} else {
store[referenceNo] = now + ttlMillis
false
}
}
@PostConstruct
fun startCleanupTask() {
cleaner = Executors.newSingleThreadScheduledExecutor()
cleaner.scheduleAtFixedRate({
val now = Instant.now().toEpochMilli()
store.entries.removeIf { it.value < now }
}, 1, 1, TimeUnit.MINUTES)
}
@PreDestroy
fun stopCleanupTask() {
cleaner.shutdown()
}
}
🔍 Code Breakdown
⏳ What is TTL (Time-To-Live)?
TTL (Time-To-Live) defines how long a piece of data should stay valid.
In our case, we only want to prevent duplicates within a short window (like 5 minutes). This allows:
- Users to retry safely
- Systems to avoid infinite memory growth
- Reference numbers to “expire” after a while
private val ttlMillis = TimeUnit.MINUTES.toMillis(5)
This sets our window. If the same referenceNo
comes in after 5 minutes, it might be processed again. However, if your system performs an additional check for an existing transaction with the same referenceNo
before creating a new one, you can still prevent duplicate processing entirely.
✅ isDuplicate
This is the core method:
fun isDuplicate(referenceNo: String): Boolean
- Checks if the
referenceNo
exists and hasn’t expired - If it hasn’t been seen, it stores it with a TTL
🧹 Cleanup Task
@PostConstruct
fun startCleanupTask()
- Schedules a cleanup job every minute
- Removes expired entries from memory
store.entries.removeIf { it.value < now }
🔌 Shutdown Safety
@PreDestroy
fun stopCleanupTask()
Ensures the scheduled executor shuts down gracefully when the app exits.
⚖️ Pros and Cons
✔️ Pros
- Simple and easy to integrate
- No external dependencies
- Great for small-scale services or prototypes
❌ Cons
- Not distributed: Only works within a single instance
- Not persistent: Keys are lost on app restart
- Potential for race conditions under extreme concurrency
🔧 Improvements and Alternatives
1. Configurable TTL
Make TTL configurable via constructor or external config.
2. Distributed Storage
For high availability, store reference numbers in Redis or Hazelcast.
3. Monitoring
Add logs or metrics to track duplicate detection frequency.
📈 Real-World Use Case: Payment Gateway
In my case, I built this utility to wrap a payment gateway endpoint. Whenever a payment is initiated:
- The client sends a
referenceNo
- The backend checks
isDuplicate(referenceNo)
- If it’s already been processed recently, the request is ignored
This protects against:
- Double clicks
- Retry storms
- Poorly written clients
And it saved me from a lot of debugging.
🧩 Final Thoughts
This Kotlin utility proves that even a few lines of code can deliver real business value — like preventing duplicate transactions in your system.
While this version is intentionally lightweight and non-persistent, it lays the foundation for more robust, distributed idempotency handling in the future.
💬 What About You?
- Have you had to prevent duplicate payments?
- What strategies do you use for idempotency?
Comments
Post a Comment