DataIntermediate

An order service where money does not go missing

Orders, stock and payment. Three writes that must agree, in a system where any of them can fail after the others have already happened.

The business problem

Placing an order reserves stock, records the order and takes payment. Doing all three is easy. Doing all three or none of them, when the payment provider times out after charging the card, is the job.

Build it single-service first — one database, one transaction — so that you can feel exactly what a distributed version would be giving up.

What you will have at the end

  • A transaction boundary you chose rather than inherited
  • Idempotent order placement — a retried request does not create a second order
  • Optimistic locking on stock, and a tested race
  • A state machine the code actually follows

Milestones

Each one ends in something you can observe. Without that a milestone is a heading, and you have no way to know you finished.

  1. The state machine

    PENDING → CONFIRMED → SHIPPED, plus CANCELLED. Write the legal transitions down before any code.

    done whenAn illegal transition is rejected by the domain, not by a missing branch.

  2. One transaction, drawn deliberately

    @Transactional on the service method, not the repository. Know what happens to a @Transactional method called from inside the same class.

    done whenA failure in the third write rolls back the first two, and you have a test that proves it.

  3. Idempotency

    An Idempotency-Key header, stored with a unique constraint. The second request returns the first order.

    done whenThe same request sent twice creates one order and returns 200 the second time.

  4. The stock race

    @Version on the stock row. Two concurrent orders for the last unit: one wins, one gets a clean 409.

    done whenA Testcontainers test runs both concurrently and stock never goes negative.

  5. The remote call that must not be in the transaction

    The payment call is a network hop. Holding a database transaction open across it is how a connection pool dies at 200 rps.

    done whenThe transaction commits before the remote call, and you can say what happens if the call then fails.

Data

orders, order_items, stock with a version column, and an idempotency_keys table. The version column is the whole of milestone four.

Trade-offs you will have to defend

Optimistic locking is cheap and fails late. Pessimistic locking serialises and fails early. Which is right depends on how often two people really do buy the last unit.
Reserving stock at checkout is honest and holds inventory hostage. Reserving at payment is efficient and oversells.
One service with one transaction is boring and correct. Splitting it is a decision that buys independent scaling and costs you the transaction.