A consumer event ledger uses a unique event ID to reject a repeated delivery before it repeats a business mutation.
Spring consumer idempotency: reserve an event ID with the stock mutation
One transaction covers both writes
The consumer inserts EVENT-23 into applied_event and subtracts two units from SKU-17 inside one TransactionTemplate callback. The first call changes stock from 10 to 8; the second insert violates the event-ID primary key and the method reports a duplicate without another stock change. The constraint, not a preflight SELECT, decides the race.
If the stock item does not exist, the callback throws and rolls back the event reservation. The test then creates the item and retries the same event successfully. A reservation committed before the business mutation would instead suppress a valid retry. Transaction boundaries matter more than a boolean already-seen check.
Idempotent result has a scope
This model makes the database mutation idempotent for one event ID in one datasource. It does not make an email, webhook, or file write automatically idempotent. The ledger key must be stable across retries and unique in the relevant tenant or source namespace. Retention cannot expire the key while the producer may still redeliver the event.
The test uses H2 and sequential calls. On a target database, test two concurrent consumers inserting the same key, transaction isolation, statement timeout, and rollback visibility. A relay lease reduces duplicate work; the consumer constraint handles duplicates that still reach it.
Checked source
transactions.executeWithoutResult(status -> {
jdbc.update("insert into applied_event values (?)", eventId);
int changed = jdbc.update("update stock_balance set available = available - ? where sku = ?", units, sku);
if (changed != 1) throw new IllegalStateException("unknown stock item");
});Verification boundary
OutboxRelayFlowTest has seven local tests in the downloadable Spring source kit. The excerpt is shortened; the kit contains the complete test.
Costs and limits
The excerpt shortens the checked fixture, which reads units and SKU from the outbox. No external broker, tenant registry, stock floor constraint, or concurrent consumer race is included.
Common Mistakes
- Do not check existence then insert without a unique constraint.
- Do not commit the event marker before the business change.
- Do not use a fresh event ID on a redelivery.
Read next
Spring outbox relay: claim, deliver, and acknowledge one event, Spring outbox claims: lease expiry and token-fenced acknowledgement, Spring transaction propagation: joined rollback and independent commit, Java JDBC isolation: uncommitted writes and separate sessions.
Continue with scheduled relay checks
Continue with Spring scheduled relay: recover when the consumer commits before acknowledgement.
