A Small Fintech Challenge, and Why I Let Postgres Hold the Money

jeyem1 pts0 comments

A Small Fintech Challenge, and Why I Let Postgres Hold the Money — How ACID transactions and row-level serialization turned a hard concurrency problem into a boring one<br>A Small Fintech Challenge, and Why I Let Postgres Hold the Money<br>I picked up a small backend challenge recently: build a marketplace where guilds buy and auction items using in-game gold. Listings, auctions, bids, a price oracle, automatic settlement. On the surface it&rsquo;s a CRUD app.<br>The full source is on GitHub: github.com/jeyem/dragon-market.<br>It isn&rsquo;t. The moment money moves and two people act at the same time, it becomes the only problem that matters:<br>A marketplace can lose a feature and survive. It cannot lose a coin and survive.

Two guilds bid on the same auction in the same millisecond. A buyer spends gold they already spent on something else a microsecond earlier. An auction settles twice because a retry fired. Every one of these is a money bug, and every one of them is a race.<br>The interesting part of the project wasn&rsquo;t the API. It was deciding who is responsible for correctness . I decided it shouldn&rsquo;t be my Go code. It should be Postgres.<br>The temptation, and why I skipped it<br>The instinct is to reach for application-level concurrency control: a mutex around the bid handler, an in-memory queue per auction, a distributed lock in Redis. It feels like control.<br>It&rsquo;s also a trap. App-level locks don&rsquo;t survive a second instance of your service. They don&rsquo;t survive a crash mid-operation. They don&rsquo;t roll back. And they sit outside the system that actually stores the truth, so they&rsquo;re always one deploy away from being bypassed.<br>Postgres already solves this, and it solves it with guarantees that have been hardened for decades. So the whole design became one sentence: every operation that touches money is a single database transaction, and the database serializes the parts that conflict.<br>Balances you can&rsquo;t corrupt because they don&rsquo;t exist<br>The first decision was to not store balances at all.<br>There&rsquo;s no balance column anywhere. A wallet is derived from an append-only ledger:<br>total = sum(grant, credit) - sum(debit)<br>reserved = sum(reserve) - sum(release)<br>available = total - reserved<br>This sounds like a performance compromise. It&rsquo;s actually a correctness gift. A mutable balance column is the classic lost-update bug waiting to happen: read 100, read 100, both subtract 30, both write 70, and 30 gold just evaporated. You cannot have that bug against a number you never update. You only ever append a fact — &ldquo;reserved 50 for this bid&rdquo; — and the balance is whatever the facts add up to. Every coin is traceable to a row.<br>Letting the database serialize the race<br>The bids are where concurrency gets real. A new bid has to be at least 5% above the current highest, and the bidder has to actually have the funds. Both checks are meaningless if another bid lands between the read and the write.<br>So the transaction takes a row lock on the auction before it reads anything:<br>SELECT ... FROM auctions WHERE id = $1 FOR UPDATE;

FOR UPDATE makes Postgres serialize every bid on that one auction. The second bidder simply waits — inside the database, holding a real lock — until the first transaction commits or rolls back. By the time they read the highest bid, it&rsquo;s the truth, not a stale snapshot. The 5% rule and the funds check are evaluated against reality and can&rsquo;t be undercut by a concurrent writer.<br>The same pattern guards a buyer&rsquo;s wallet: lock the guild row, then check available funds and the daily spend cap. No two purchases can both believe the same gold is available.<br>This is a deliberate trade. Bids on a single auction now run one at a time, so a wildly hot auction is a throughput hotspot. I kept it anyway and wrote the trade-off down: it&rsquo;s per-row, so different auctions still run fully in parallel, and I&rsquo;d take &ldquo;slower but always correct&rdquo; over &ldquo;fast and occasionally wrong&rdquo; with money every single time. Consistency first; throughput is an optimization you can do later with a clear conscience.<br>The bug the tests caught (and what it taught me)<br>I wrapped the database calls in a circuit breaker, and my first version counted business rejections — &ldquo;bid too low&rdquo;, &ldquo;insufficient funds&rdquo; — as breaker failures. A burst of perfectly valid rejections tripped the breaker, and the next legitimate request got a 500.<br>The end-to-end tests caught it immediately: a buy that should have returned 200 came back 500. The fix was a one-line distinction — only real infrastructure faults (a failed begin or commit) trip the breaker; a rolled-back business rule counts as a success, because the system did exactly what it should. The transaction had already protected the data. The breaker just had the wrong opinion about what &ldquo;failure&rdquo; means.<br>The takeaway<br>The lesson I keep relearning: the database is not just where data rests,...

rsquo auction money postgres database ldquo

Related Articles