A POST /charges can appear to fail for the client after the server has committed the charge to the database. The client sees a timeout, repeats the POST, and, without an idempotency contract, creates another charge. Even after solving that problem, another one remains: the commit can succeed and the event publication can fail immediately afterward.
These are two different failures:
Idempotency-Keyprotects the HTTP operation against repeated submissions of the same request;- transactional outbox protects the handoff between the database transaction and asynchronous delivery to the broker.
An idempotency key does not recover a lost event. An outbox does not prevent two legitimate calls to the service from creating two charges. Putting both names in the same diagram does not turn them into the same mechanism—architecture does not work through typographic proximity either.
The implementation below writes the key, the charge, and the outbox event in a single JDBC transaction. A separate process publishes the event. At the end, a test sends two identical requests and queries Postgres to prove that there is one charge and one outbox row.
Versions and dependencies
The example uses Spring Boot 4.1.1, Java 25 LTS, and Spring Framework 7.0.9 or later. This is a supported combination: the Spring Boot 4.1.1 requirements accept Java 17 through 26 and require Spring Framework 7.0.9+. Boot 4.2 is still in preview, so it is not the foundation for a production example.
The pom.xml needs these dependencies. Versions managed by the Boot 4.1.1 BOM include the PostgreSQL driver and Testcontainers 2.0.5:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
In Testcontainers 2, note the org.testcontainers.postgresql.PostgreSQLContainer package and the testcontainers-postgresql artifact. Copying the import from the 1.x line is a remarkably efficient way to start the day with a compilation error.
The Idempotency-Key contract
The client creates a key for one logical attempt and sends the same key on every retry for that attempt:
POST /charges HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
{"amountCents":4900}
The IETF document for the header is an expired Internet-Draft, not an RFC. The draft describes the value as a Structured Fields String, whose RFC 8941-compliant form appears in quotation marks:
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
In practice, many clients send an unquoted UUID. The API can accept both forms, normalize them to the UUID, and document that behavior. It should not claim generic support for every Structured Field if the parser merely removes two quotation marks.
Our contract is explicit:
- the header is required; a missing header or invalid UUID returns
400; - uniqueness applies to
(key, endpoint), not to the key across the entire system; - the same key with different content returns
422; - a concurrent operation that is still processing returns
409; - a completed operation returns the stored status and body;
- retention is configured and documented by the API.
The draft does not define a universal window. Stripe, for example, documents removing keys after at least 24 hours. That is a Stripe policy, not an Internet standard. This service will use 24 hours as a local decision; the client must know this window because an expired key is treated as new again.
In production, count the created, replayed, payload_conflict, and in_flight results by endpoint without putting the key in metric labels. An increase in replays reveals actual retries; conflicts point to incorrect key reuse by the client. The key can appear in structured logs with retention and access controls consistent with the system policy, but it should not become a high-cardinality label.
Schema: key, aggregate, and outbox
The request_hash acts as the request fingerprint. Here it will be the SHA-256 of a canonical representation of the fields that change the charge. This way, irrelevant JSON whitespace differences do not turn the same operation into a different one.
CREATE TABLE charges (
id UUID PRIMARY KEY,
amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE idempotency_keys (
key TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('STARTED', 'COMPLETED')),
response_status INTEGER,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (key, endpoint)
);
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_id UUID NOT NULL REFERENCES charges(id),
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_pending_idx
ON outbox (created_at)
WHERE published_at IS NULL;
The composite constraint resolves the race between two requests that did not find the key and then tried to insert it. Do not use ON CONFLICT DO UPDATE to overwrite a completed response. For retention, a job can remove COMPLETED records whose expires_at has passed; old STARTED records require a separate recovery policy, not optimistic cleanup.
Controller and JDBC transaction
The controller keeps the endpoint stable and gives the service an already deserialized object:
package academy.devdojo.charges;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/charges")
class ChargeController {
private final ChargeService service;
ChargeController(ChargeService service) {
this.service = service;
}
@PostMapping
ResponseEntity<String> create(
@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest request) {
ChargeResult result = service.charge(key, "/charges", request.amountCents());
return ResponseEntity.status(result.status())
.contentType(MediaType.APPLICATION_JSON)
.body(result.body());
}
@ExceptionHandler(InvalidIdempotencyKey.class)
ResponseEntity<Void> invalidKey() {
return ResponseEntity.badRequest().build();
}
@ExceptionHandler(InFlightRequest.class)
ResponseEntity<Void> inFlight() {
return ResponseEntity.status(409).build();
}
@ExceptionHandler(PayloadConflict.class)
ResponseEntity<Void> payloadConflict() {
return ResponseEntity.unprocessableEntity().build();
}
}
record ChargeRequest(int amountCents) {}
record ChargeResult(int status, String body) {}
The service performs four operations in the same transaction: reserves the key, creates the charge, creates the outbox entry, and saves the replayable response.
package academy.devdojo.charges;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ChargeService {
private final JdbcClient jdbc;
public ChargeService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Transactional
public ChargeResult charge(String rawKey, String endpoint, int amountCents) {
String key = normalizeUuid(rawKey);
String hash = sha256("amountCents=" + amountCents);
Optional<IdempotencyRow> previous = jdbc.sql("""
SELECT request_hash, status, response_status, response_body
FROM idempotency_keys
WHERE key = :key AND endpoint = :endpoint
""")
.param("key", key)
.param("endpoint", endpoint)
.query((rs, rowNum) -> new IdempotencyRow(
rs.getString("request_hash"),
rs.getString("status"),
rs.getObject("response_status", Integer.class),
rs.getString("response_body")))
.optional();
if (previous.isPresent()) {
IdempotencyRow row = previous.get();
if (!row.requestHash().equals(hash)) {
throw new PayloadConflict();
}
if (!"COMPLETED".equals(row.status())) {
throw new InFlightRequest();
}
return new ChargeResult(row.responseStatus(), row.responseBody());
}
try {
jdbc.sql("""
INSERT INTO idempotency_keys
(key, endpoint, request_hash, status, expires_at)
VALUES
(:key, :endpoint, :hash, 'STARTED', now() + interval '24 hours')
""")
.param("key", key)
.param("endpoint", endpoint)
.param("hash", hash)
.update();
} catch (DuplicateKeyException race) {
throw new InFlightRequest();
}
UUID chargeId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO charges (id, amount_cents)
VALUES (:id, :amount)
""")
.param("id", chargeId)
.param("amount", amountCents)
.update();
String eventPayload = "{\"chargeId\":\"" + chargeId
+ "\",\"amountCents\":" + amountCents + "}";
jdbc.sql("""
INSERT INTO outbox (id, aggregate_id, event_type, payload)
VALUES (:id, :aggregateId, 'ChargeCreated', :payload)
""")
.param("id", UUID.randomUUID())
.param("aggregateId", chargeId)
.param("payload", eventPayload)
.update();
String responseBody = "{\"id\":\"" + chargeId + "\"}";
jdbc.sql("""
UPDATE idempotency_keys
SET status = 'COMPLETED',
response_status = 201,
response_body = :body
WHERE key = :key AND endpoint = :endpoint
""")
.param("body", responseBody)
.param("key", key)
.param("endpoint", endpoint)
.update();
return new ChargeResult(201, responseBody);
}
private static String normalizeUuid(String rawKey) {
String candidate = rawKey;
if (rawKey.length() >= 2 && rawKey.startsWith("\"") && rawKey.endsWith("\"")) {
candidate = rawKey.substring(1, rawKey.length() - 1);
}
try {
return UUID.fromString(candidate).toString();
} catch (IllegalArgumentException exception) {
throw new InvalidIdempotencyKey();
}
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(
digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossibleOnJava25) {
throw new IllegalStateException("SHA-256 indisponível", impossibleOnJava25);
}
}
private record IdempotencyRow(
String requestHash,
String status,
Integer responseStatus,
String responseBody) {}
}
class InvalidIdempotencyKey extends RuntimeException {}
class InFlightRequest extends RuntimeException {}
class PayloadConflict extends RuntimeException {}
There is no call to Kafka, RabbitMQ, or HTTP in this method. If any INSERT or UPDATE fails, @Transactional rolls back the key, charge, and outbox together. The method must be called through the Spring proxy; a this.charge(...) call from inside the same bean does not trigger the expected transactional interception.
The race on the INSERT results in a rollback and 409. After the first request finishes, the client can retry and receive the stored response. To observe atomicity in production, alert on any charge without a corresponding outbox entry and monitor idempotency constraint violations. This reconciliation query should run outside the HTTP path.
Relay with SKIP LOCKED
The relay has a separate transaction. In Postgres, FOR UPDATE SKIP LOCKED lets multiple workers take distinct batches from the table without waiting for rows that are already locked:
package academy.devdojo.charges;
import java.util.List;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class OutboxRelay {
private final JdbcClient jdbc;
private final EventPublisher publisher;
OutboxRelay(JdbcClient jdbc, EventPublisher publisher) {
this.jdbc = jdbc;
this.publisher = publisher;
}
@Transactional
public int publishBatch() {
List<OutboxRow> rows = jdbc.sql("""
SELECT id, aggregate_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 50
FOR UPDATE SKIP LOCKED
""")
.query((rs, rowNum) -> new OutboxRow(
rs.getObject("id", UUID.class),
rs.getObject("aggregate_id", UUID.class),
rs.getString("event_type"),
rs.getString("payload")))
.list();
for (OutboxRow row : rows) {
publisher.publish(row.eventType(), row.aggregateId(), row.payload());
jdbc.sql("""
UPDATE outbox SET published_at = now() WHERE id = :id
""")
.param("id", row.id())
.update();
}
return rows.size();
}
private record OutboxRow(
UUID id, UUID aggregateId, String eventType, String payload) {}
}
@Component
class OutboxScheduler {
private final OutboxRelay relay;
OutboxScheduler(OutboxRelay relay) {
this.relay = relay;
}
@Scheduled(fixedDelayString = "${outbox.poll-delay:1s}")
void poll() {
relay.publishBatch();
}
}
interface EventPublisher {
void publish(String eventType, UUID aggregateId, String payload);
}
The application also needs @EnableScheduling on a configuration class. SKIP LOCKED produces a deliberately incomplete view: a locked row disappears from that batch. That is appropriate for a queue, not for a reporting query.
Delivery is at least once. If the broker accepts the message and the process dies before setting published_at, the next cycle will publish it again. Therefore, the consumer must deduplicate by the outbox id; do not promise exactly-once just because the UPDATE looks confident.
Keep batches small, define a publication timeout, and record failures without marking the row as published. Useful observations are the age of the oldest pending row, the pending total, the publication rate, and errors by destination. A rising age detects a stuck relay even when the scheduler continues waking up right on time.
MySQL 8 also supports SKIP LOCKED. Another option is to serialize the entire poller with GET_LOCK('outbox-relay', 0) and release it with RELEASE_LOCK; this reduces concurrency but can be sufficient for a single relay per database.
Testing with real Postgres
Because schema.sql is not automatically applied to non-embedded databases, the test sets spring.sql.init.mode=always. Testcontainers provides the connection details through @ServiceConnection:
package academy.devdojo.charges;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.sql.init.mode=always")
@Testcontainers
class ChargeIdempotencyIT {
@Container
@ServiceConnection
static PostgreSQLContainer postgres =
new PostgreSQLContainer("postgres:16-alpine");
@LocalServerPort
int port;
@Autowired
JdbcClient jdbc;
@MockitoBean
EventPublisher publisher;
private final HttpClient http = HttpClient.newHttpClient();
@BeforeEach
void cleanDatabase() {
jdbc.sql("TRUNCATE outbox, idempotency_keys, charges").update();
}
@Test
void sameKeyCreatesOneChargeAndOneOutboxRow() throws Exception {
String key = "8e03978e-40d5-43e8-bc93-6894a57f9324";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/charges"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString("{\"amountCents\":4900}"))
.build();
HttpResponse<String> first =
http.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> replay =
http.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(201, first.statusCode());
assertEquals(201, replay.statusCode());
assertEquals(first.body(), replay.body());
assertEquals(1L, count("charges"));
assertEquals(1L, count("outbox"));
assertEquals(1L, count("idempotency_keys"));
}
private long count(String table) {
return jdbc.sql("SELECT count(*) FROM " + table)
.query(Long.class)
.single();
}
}
The table name in the helper does not come from external input; all three values are test constants. In application code, concatenating a user-provided name would still be SQL injection, even with a very friendly test sitting next to it.
This case covers a completed replay. The suite should add three checks: the same key with a different value returns 422; two overlapping calls produce one winner and one 409; a relay failure leaves published_at null for another attempt. To validate the operational contract, monitor the same replay, conflict, and outbox-age metrics in the test environment that you use in production.
When to adopt it
Adopt Idempotency-Key plus outbox when an HTTP command can be repeated and its commit must trigger reliable asynchronous integration. If the operation is read-only or produces no external effect after the commit, one of the mechanisms may be unnecessary; keep the distinction clear before maintaining two tables out of habit.
The next step is to run the test with Postgres, then interrupt the publisher between sending the message and the outbox UPDATE. When the message reappears without creating another charge, the system will be demonstrating both guarantees separately—which is exactly the point.