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.
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.
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.
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.
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.
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.