XY

Transactions · APIs

Idempotency Is a Product Guarantee, Not an HTTP Header

The header only names a request. The database transaction, uniqueness rule, replay semantics, and side-effect boundary are what prevent a customer from paying twice.

Decision map One request identity, one committed result

The fast read handles ordinary replay. The transaction and unique constraint close the concurrent race; the loser returns the winner's result.

  1. 01 Request

    Stable client idempotency key

  2. 02 Replay read

    Return an existing order without another write

  3. 03 Transaction

    Order, items, and stock move together

  4. 04 Database gate

    Unique key plus conditional stock update

  5. 05 Response

    Winner creates; concurrent loser replays

The header is a label

Adding Idempotency-Key to an endpoint does not make the endpoint idempotent. It gives the server a stable request identity. The guarantee appears only when concurrent requests, database state, response replay, and external side effects all agree on what that identity means.

In an order API the customer-level promise is simple: if the network times out and the client retries, the server will not create a second order or decrement stock twice. That promise must hold when the retries arrive sequentially and when they arrive at the same instant on different threads.

Idempotency is observable product behavior under uncertainty—not a field in an API example.

Let the database close the race

Catalog Order Service uses a fast lookup for the ordinary replay path and a database UNIQUE constraint for the concurrent path. If the key already has an order, the service returns that order. If two requests both miss the initial read, only one insert can win. The loser handles the constraint conflict and re-reads the winner.

The same principle protects inventory. A conditional update claims stock only when enough quantity remains. With one unit and two buyers, one statement affects one row and the other affects zero. No application-level read-then-write window gets to oversell the product.

UPDATE products
SET stock_quantity = stock_quantity - :quantity
WHERE id = :productId
  AND stock_quantity >= :quantity;

One request needs one transactional story

An order with several line items is not partially useful. The service combines duplicate product lines, processes product IDs in stable sorted order, creates the order, inserts its items, and deducts every quantity inside one transaction. If a later product is missing or out of stock, earlier changes roll back with it.

Stable ordering reduces avoidable deadlock risk when concurrent orders touch the same set of products in different client order. It does not eliminate the need for database-level correctness; it makes contention more predictable while the conditional update remains the authority.

  • Validate and aggregate the request before taking stock.
  • Use a unique idempotency key to select one winning order.
  • Claim inventory with conditional SQL, not an unlocked read followed by a write.
  • Commit order, line items, and deductions together—or keep none of them.

Replay means replaying semantics

A retry should receive the original resource rather than a newly generated approximation. The service returns the winning order and does not execute stock deduction again. That makes the result useful to clients that lost the original response after the server committed.

A production API should also decide what happens when a client reuses the same key with a different payload. The focused demo proves same-key replay and the concurrent race. A larger system should persist a canonical request fingerprint and return a clear conflict when identity and payload disagree.

Side effects start after commit

Shipping illustrates the boundary between database truth and external IO. The order may transition from CREATED to SHIPPED once. Webhook delivery begins after that transaction commits, so a slow or failing receiver cannot hold the database transaction open. Retries are asynchronous and stop after three attempts.

That is sufficient to demonstrate bounded failure, but in-process retry is not durable delivery. A production version should write an outbox record in the shipping transaction, then let a durable worker claim and deliver it. The idempotency contract must extend to the receiver because at-least-once delivery can repeat a successful call whose acknowledgement was lost.

Evidence is the concurrent test

The most important test does not call the service twice in a loop. It starts two threads against stock quantity one and verifies one success, one rejection, and final stock zero. The 44-test suite also covers repeated keys, transactional rollback, shipping conflicts, and bounded webhook retries.

That proof is more valuable than a badge that says idempotent. It states the invariant, creates the race, and verifies the stored result after both contenders finish.

What I would improve next

I would replace embedded H2 with PostgreSQL and versioned migrations, then run the concurrency suite against the production database engine. I would add request fingerprints, a durable transactional outbox, authenticated webhook signing, destination allowlisting, and operator-visible redrive controls.

The design goal would remain the same: a client should be able to retry because the network is unreliable without making the business operation unreliable too.

Source notes

Claims you can inspect.

  1. Code OrderService.java · replay and race handling ↗

    Fast replay, transactional creation, and unique-constraint conflict re-read in the implemented service.

  2. Code ProductRepository.java · conditional stock claim ↗

    The database update that prevents stock from going negative under concurrency.

  3. Code StockConcurrencyTest.java · two-thread proof ↗

    Two simultaneous buyers compete for one remaining unit.

  4. Reference AWS Builders' Library · Making retries safe ↗

    A production account of client request identifiers, atomicity, replay semantics, and changed intent.