Annotated methods

@Idempotent on any Spring bean - SpEL keys, every attribute, and why consumer threads set waitTimeout to zero.

@Idempotent works on any Spring bean method - a listener, a consumer, a service method. It needs no web stack.

A method has no transport to take a key from, so you write a SpEL expression over its parameters:

@Component
class OrderListener {

    @Idempotent(key = "#event.id()", waitTimeout = "PT0S")
    @KafkaListener(topics = "orders")
    void on(OrderPlaced event) {
        // Runs once per event id, however many times the broker redelivers.
    }
}
Java

Every attribute

@Idempotent(
    key = "#event.id()",        // SpEL over the method's parameters. Required here
    scope = "",                 // Empty: <simple class name>.<method name>
    ttl = "PT24H",              // How long the record stays replayable (ISO-8601)
    lease = "PT30S",            // How long this acquisition is protected
    waitTimeout = "PT10S",      // How long a concurrent caller blocks. "PT0S" to not block
    completion = "",            // autonomous | join-transaction
    codec = ""                  // PayloadCodec bean name, for a method that returns a value
)
Java

ttl, lease, waitTimeout and completion take the application default from configuration when left empty. key has no default, an empty scope means <simple class name>.<method name>, and codec is left empty only on a void method.

Validation happens at startup

Anything malformed - an unparseable duration, an unknown completion mode, an over-long scope - is rejected when the context starts, not on the first message.

This is deliberate and it is the reason the attributes are strings. A misconfigured annotation on a rarely-hit consumer would otherwise stay invisible until the day that consumer receives traffic, which is the worst moment to discover it.

waitTimeout = "PT0S" on a consumer thread

A call that finds the key in flight throws IdempotencyInFlightException, which carries retryAfter so the broker can redeliver later. Register an OutcomeMapper bean to answer differently.

The default of PT10S suits a request thread, where blocking briefly to hand the caller the real answer beats telling them to come back. A consumer is the opposite case, which is why the default is the wrong one there.

Returning a value

A method that returns a value needs a PayloadCodec bean named in codec, so a duplicate call can be given the original answer back. The module does not guess at a serialisation format, and a value-returning method without a codec fails at startup:

@Bean
PayloadCodec<Receipt> receiptCodec() { ... }

@Idempotent(key = "#command.id()", codec = "receiptCodec")
Receipt settle(SettleCommand command) { ... }
Java

codec names the bean, so receiptCodec here is the PayloadCodec<Receipt> declared above.

A void method needs none - there is nothing to replay - and must leave codec empty.

See payloads and codecs for writing one, including what to put in attributes.

Inside a transaction

The idempotency advice always runs inside any transaction the method has: it is applied by a bean post-processor that appends itself behind the advice a bean already carries, so a @Transactional method, a @TransactionalEventListener, or Spring Modulith’s @ApplicationModuleListener is guarded from within its own transaction, whatever order the transaction advisor was given. That is what lets the default completion wait for the commit, and a joined one write the record inside it.

On an endpoint

An annotated request mapping handler belongs to the HTTP filter alone, and key, codec and completion are rejected on one at startup. The advisor that intercepts annotated methods leaves those handlers to the filter so the two never guard the same call under two different keys.