Aug 2026 — present · Java 21 · Spring Boot 3 · PostgreSQL

A banking API that refuses to create money

A Spring Boot REST API with a double-entry ledger, built to survive concurrent transfers, duplicate requests, and its own bugs.

I had a “Banking Management System” on my resume that was really a JDBC exercise: a balance column, an UPDATE, and no story about what happens when two transfers touch the same account at the same time. I rebuilt it to answer that question properly.

The core rule is that no code path may write an unpaired ledger row. Every movement of money is two entries that sum to zero. Deposits and withdrawals pair against a seeded SYSTEM-CASH-USD account rather than appearing from nowhere, and openAccount takes no opening balance, because seeded money would be money without a counterparty.

Invariant, checked ~20 times across the suite sum(credits) − sum(debits) = 0
over the whole ledger, after every test

That rule is only worth anything if something checks it. Two things do: a native findReconciliationBreaks() query that flags any account whose balance disagrees with the sum of its ledger entries, and a whole-ledger assertion that credits minus debits equals zero. The second one is what caught the bug below.

The bug that made the project

The first full run of the concurrency suite failed conservation. The ledger balanced to 2450.0000 where it should have balanced to 2000.0000. Money was being created, and nothing had gone wrong in any way the system could see: no exception, no deadlock, no failed lock acquisition.

Symptom expected 2000.0000
actual   2450.0000

Lock acquired. Query issued.
Value stale anyway.

The cause was one line that ran before the locks. resolveCurrency() called accounts.findById(sourceId) to stamp a currency onto the transfer request. That put the source Account into the Hibernate persistence context. When findByIdForUpdate ran a moment later, Hibernate did issue the SELECT ... FOR UPDATE — the lock was genuinely taken — but the first-level cache returned the already-loaded instance and discarded the fresh column values.

So the lock was held over a read that had already gone stale. A textbook lost update, produced by a caching layer doing exactly what it is designed to do.

The fix is a scalar projection: findCurrencyById returns a String and materialises no entity, so nothing enters the persistence context before the lock. findByIdForUpdate now carries a comment explaining the trap, because the next person to add a convenience lookup above it will reintroduce the bug otherwise.

I wrote this one up at length.

Proving the lock ordering actually does something

Transfers lock accounts in ascending ID order, which is the standard defence against two opposing transfers deadlocking. Every test passed with that rule in place — but tests passing is not evidence that the rule is why.

So I removed it. I replaced the ascending-ID ordering with plain source-then-destination locking and re-ran the opposing-transfer test.

Ordering removed SQLSTATE 40P01
deadlock detected × 6
at ~1s intervals

Postgres detected the deadlock six times, roughly a second apart. I put the rule back and the suite went green again. The test now demonstrably fails for the right reason rather than passing by accident.

I chose pessimistic locking over optimistic for a reason I can defend: contention here is the normal case, not the exception. Under optimistic locking, retries degrade toward livelock, rollback is expensive once three rows are already written, and every retry churns the idempotency index.

Idempotency that survives a crash

A client that retries a transfer after a timeout must not move money twice. The key is claimed under a unique constraint before the account locks are taken, and TransferService is deliberately not transactional, so the duplicate-key recovery path reads in a clean transaction rather than a poisoned one.

Later I made the claim observable: it commits as CLAIMED before the money moves, then flips to COMPLETED or FAILED. This deliberately reverses a guarantee from the first version, where a failed transfer left no trace at all. The money guarantee is untouched — still zero ledger rows, still zero balance movement — but bookkeeping has to outlive the failure, or a crashed process is indistinguishable from a rejection.

That introduced its own problem, which I hadn’t been asked to solve: a committed claim plus a crashed process wedges that key at HTTP 409 permanently. Claims older than a configured timeout can now be taken over through a conditional UPDATE ... WHERE status = :expected, so the database arbitrates the takeover rather than the application racing itself.

Constraint that carries the security property UNIQUE NULLS NOT DISTINCT
(initiated_by_customer_id,
 idempotency_key)

Idempotency keys are scoped per caller. The NULLS NOT DISTINCT is load-bearing: the customer column is nullable for system-initiated movements, and Postgres treats NULLs as distinct by default, so a plain composite unique would have enforced nothing on exactly the rows nobody is watching.

Authorisation, and one trap worth naming

Ownership is enforced with @PreAuthorize on the source account only — you may transfer into an account you don’t own, but not out of one. The refused transfer leaves transfer_request.count() unchanged, which proves the check short-circuits before the service stakes a claim or takes a lock.

The trap: the transfer endpoint’s expression names a field on the request body, not #accountId. Copying #accountId from the account-scoped endpoints would still have compiled. SpEL resolves an unknown reference to null, and the ownership predicate fails closed on a null ID, so the endpoint would have refused everyone and looked correctly annotated while doing it. Against a predicate that failed open instead, the money-moving endpoint would have been completely unguarded. Both directions are tested.

Login returns a byte-identical body for an unknown user and a disabled account, and hashes a decoy password when no user matches — otherwise the obvious early return answers in microseconds while a real wrong-password attempt takes BCrypt-slow time, and the timing difference is an account enumeration oracle.

Where it stands

Suite 54 tests · 0 failures · 0 skipped
12 test classes
Postgres via Testcontainers

Phases 1 and 2 are complete and green. Known limitations are written down rather than hidden: only a USD system cash account exists, so non-USD accounts can’t yet be funded; there is no lock_timeout; access tokens last fifteen minutes with no refresh or revocation, though the JWT filter re-reads the user on every request, so disabling an account takes effect immediately.

Built with Java 21, Spring Boot 3, PostgreSQL, JPA/Hibernate, Flyway, Spring Security + JWT, Testcontainers, Maven · Source

← All projects