Backend · Personal project
GoBank
A small bank in Go that keeps every balance correct when services fail.

- Go tests against a real Postgres, no mocks
- 251
- confirmed transactions lost in four database failovers
- 0
- peak transfers in a load test, with no errors
- 176/s
- ledger invariant violations across 53,789 load-test transfers
- 0
The problem
Moving money looks simple until something fails halfway. A client retries, a message arrives twice, or the database leader dies in the middle of a transfer.
I built GoBank to find out what it takes to keep every balance correct anyway, and to check it with tests and measurements instead of assumptions.
What I built
Customers register with an email code, top up with a Stripe test card, send money to another customer’s IBAN, and watch both balances update without reloading the page.
Behind the interface are six Go services and a custom gateway. They call each other over gRPC, publish events through Kafka, and store data in a three-node Postgres cluster with automatic failover. Every movement of money is a balancing pair of entries in an append-only ledger.
Key decisions
An account can’t go negative
- Problem
- Two transfers on the same account can both read “funds available” before either writes.
- Decision
- The ledger locks both rows with SELECT … FOR UPDATE, always in the same order, before it checks the balance.
- Evidence
- 20 concurrent transfers against a 10,000-unit account: exactly 10 succeed.
A retry never moves money twice
- Problem
- Clients retry, Kafka redelivers, and Stripe can send the same webhook more than once.
- Decision
- Transfers carry an idempotency key, and events go through a transactional outbox written in the same Postgres transaction as the change.
- Evidence
- Under load with deliberately duplicated requests, each transfer ran once and every duplicate got the stored result.
An unknown outcome is resolved, not guessed
- Problem
- A call to the ledger can succeed while its response is lost.
- Decision
- The transfer stays pending, and a background worker asks the ledger by reference before it completes or fails the transfer.
- Evidence
- With the fraud service stopped, a transfer answered “pending” and the worker closed it as failed 27 seconds later. No money moved.
Killing the database leader loses nothing
- Problem
- A naive failover can promote a replica that missed a confirmed commit.
- Decision
- Patroni runs in synchronous mode, so only a standby that has every confirmed commit can become the leader.
- Evidence
- Four leader kills: 23.6 to 25.4 seconds without writes, and every confirmed transaction was on the new leader.
A balance on screen is never stale
- Problem
- WebSocket messages can arrive late, out of order or not at all.
- Decision
- The socket only says that a balance changed. The client then reads the balance and the history over HTTP.
- Evidence
- Two phones side by side: the recipient’s balance, history and notification update without a reload.
Every transfer leaves a trace
- Problem
- A slow transfer crosses five services, and logs alone can’t say where the time went.
- Decision
- Every service exports OpenTelemetry spans to Jaeger, down to each SQL statement, commit and wait for a connection. Credentials, emails, card data and account numbers never go into a span.
- Evidence
- One completed transfer: 58 spans across five services in 201 ms.
What I measured
A k6 load test drives transfers through the gateway, so each one exercises the full path: five gRPC calls and four commits. Spread across many accounts, throughput peaks at 176.4 transfers per second on one laptop. On a single busy account it stays near 31.5 per second at every load level, because those transfers are serialised by design.
Across all twelve runs there were no server errors, no dropped connections and no unresolved transfers.
After every profile, eight SQL checks verify the ledger itself: all entries sum to zero, every completed transfer has exactly one debit and one credit, and every cached balance matches the log. All eight passed across 53,789 transfers and 87,888 ledger entries.
Traces showed where the time goes. The slowest transfer at 120 virtual users spent 441 of its 841 ms waiting for a database connection, because the ledger’s pool was still at its default of 16. On a calm request, four synchronously replicated commits take 92.7 of 126 ms: the price of a failover that loses nothing.
More screenshots



Limits and next steps
GoBank runs locally with fictional accounts, Stripe test mode and a fictional bank code. There is no real money, identity checking or production deployment, and the load figures come from one laptop.
The README tracks the remaining work on the outbox relay and connection-pool sizing.