The engine behind every backend: one state machine, one store contract, one serialization pipeline. Whether you annotate a controller, call IdempotentService from a job, or plug in your own database — the guarantees are identical.
@Idempotent annotation |
IdempotentService (programmatic) |
|
|---|---|---|
| Use when | Spring-managed methods (controllers, services, @Scheduled) |
Code outside AOP — message consumers, batch jobs, libraries |
| Requires | spring-boot-starter-aop |
Nothing extra |
| Key source | HTTP header (wins) or SpEL | Explicit String you provide |
| Return type | Inferred from method signature | Explicit Class<T> (preferred) or Object.class |
| TTL | duration = "5m" | "PT5M" | "100ms" |
Duration parameter |
You can mix both in the same app — they share the same IdempotentStore.
Pulled in transitively by every storage module. Add directly if you implement your own store:
<dependency>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent-core</artifactId>
<version>${idempotent.version}</version>
</dependency>With no storage module on the classpath, InMemoryIdempotentStore is auto-configured — ideal for tests and local development.
@Idempotent(key = "#orderId", duration = "5m", hashKey = false)
public Order fulfill(String orderId) { ... }| Attribute | Default | Meaning |
|---|---|---|
key |
"" |
SpEL expression. Combined with HTTP X-Idempotency-Key when present — header wins. |
duration |
"PT5M" |
Entry TTL. Accepts ISO-8601 (PT5M) or Spring short form (5m, 100ms). |
hashKey |
false |
Store SHA-256 of the key (handy for large request bodies or PII). |
Empty key? The method runs without idempotency and the library logs a warning once per method — so misconfiguration is impossible to miss in production logs.
// Same key + different process name = independent entries
Order order = idempotentService.execute(
"order-789", // key
"fulfill-order", // process scope
Order.class, // explicit return type (preferred)
() -> fulfillment.run("order-789"),
Duration.ofMinutes(30));Other overloads exist — execute(key, supplier, ttl), execute(key, processName, supplier, ttl), untyped variants — see the Javadoc.
| Situation | Behavior |
|---|---|
Operation succeeds (incl. null / void) |
Cached as COMPLETED for the TTL |
| Operation throws | In-progress entry removed; exception propagates unchanged |
Operation returns non-2xx ResponseEntity |
Entry removed; client can retry |
| Wait budget exhausted | IdempotentWaitExhaustedException |
| Two callers race the strict insert | Loser sees IdempotentKeyConflictException, re-fetches, joins the existing flow |
IdempotentException and IdempotentWaitExhaustedException are library-only. Your domain exceptions stay yours.
| Property | Default | Description |
|---|---|---|
idempotent.key.header |
X-Idempotency-Key |
HTTP header consulted by the aspect |
idempotent.inprogress.max.retries |
5 |
Polls while another caller holds IN_PROGRESS |
idempotent.inprogress.retry.initial.interval |
PT0.1S |
Initial backoff (100ms, PT0.1S, …) |
idempotent.inprogress.retry.multiplier |
2 |
Exponential multiplier |
All persistent stores route responses through one IdempotentPayloadCodec.
| Property | Default | Description |
|---|---|---|
idempotent.serialization.strategy |
json |
json (Jackson) or java (requires Serializable) |
json enables permissive polymorphic typing by default — convenient for Object.class reads, with a startup warning. Lock it down with a customizer bean:
@Bean
IdempotentJsonMapperCustomizer customizer() {
var ptv = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType("com.myapp.")
.build();
return builder -> builder.polymorphicTypeValidator(ptv);
}Or replace serialization entirely with your own IdempotentPayloadCodec bean.
Implement loadValue, store, update, and remove. getValue is a default on IdempotentStore (expiry + lazy delete over loadValue) — callers use it; do not override it in custom stores.
| Method | Contract |
|---|---|
getValue(key, type) |
null if absent or expired (default) |
loadValue(key, type) |
null if absent |
store(key, value) |
Strict insert; IdempotentKeyConflictException if the key exists |
update(key, value) |
No-op if absent |
remove(key) |
Idempotent delete |
@Bean
@Primary
IdempotentStore myStore() {
return new MyIdempotentStore();
}Back to the project overview.