Money systems have one rule above all others: the numbers have to be right, every time, forever. A social feed can drop a like and nobody notices. A wallet that loses a rupee has a bug that will eventually cost real money and real trust. The way serious payment systems get this right is not clever code. It is a four-hundred-year-old accounting technique called double-entry bookkeeping, applied to a database.
The core problem
The tempting design is a balance column: each user has an account with a number, and you add and subtract from it. This is wrong for a payments system, and it is wrong in a way that hurts you slowly. A single mutable balance has no history, no way to audit how it got to its value, and no protection against a partial failure that debits one account without crediting the other. When a support ticket asks "why is my balance 40 and not 50", a balance column has no answer.
Key design decisions
Model money as an immutable ledger, not a mutable balance. Instead of updating a number, you append entries. Every movement of money is a transaction made of at least two entries: a debit from one account and a credit to another, and the two must sum to zero. Money is never created or destroyed, only moved. The balance of any account is the sum of its entries. You never overwrite; you only append.
Enforce that every transaction balances. A transfer of 100 from Alice to Bob is one transaction with two entries: debit Alice 100, credit Bob 100. Sum is zero. If your code ever tries to write a transaction whose entries do not net to zero, that is a bug you want the database to reject, not to silently store.
Wrap the entries in a real database transaction. Both entries commit together or neither does. This is exactly what ACID transactions are for. A relational database with strong consistency is the natural home for the ledger. The two entries of a transfer are written inside one BEGIN and COMMIT so there is no window where money left one account but never arrived in the other.
Use idempotency keys for every write. The client retries. The network duplicates. If a user taps Send twice, or the app retries a timed-out request, you must not move the money twice. Each transaction carries a client-supplied idempotency key, and the ledger rejects a second write with the same key by returning the original result.
Decide how to derive balances. Summing every entry on every read is correct but slow once an account has millions of entries. The common fix is a running balance stored alongside each entry, or periodic snapshots you sum forward from. The ledger stays the source of truth; the cached balance is a derived, verifiable optimization.
Handling money that is in flight
Real transfers are not always instant. An external payout might be pending for hours. Model this with account states or with hold entries so that pending money is reserved but not yet settled. This is also how you represent authorizations, refunds, and reversals: a reversal is not an edit, it is a new balancing transaction that undoes the old one, leaving both visible in history.
How the real systems do it
Stripe, banks, and serious fintech ledgers all run on double-entry. The ledger is append-only, every movement balances to zero, and reconciliation jobs continuously sum accounts to prove the books are consistent. Idempotency keys are exposed all the way out to the public API. The audit trail is not a feature bolted on later; it falls out for free because the ledger never forgets. When regulators or a customer ask what happened, the answer is the full list of immutable entries.
The lesson that transfers to any interview: for money, never store a balance you can mutate. Store the movements, make them balance, wrap them in a transaction, and make them idempotent. Correctness comes from the model, not from being careful.
I wrote the full breakdown, with the schema and the reconciliation flow, here: https://www.systemdesign.academy/interview/design-digital-wallet
Top comments (1)
Great write-up. Using double-entry bookkeeping as the foundation for a digital wallet is a strong architectural choice because it provides consistency, traceability, and a clear audit trail. Financial systems benefit from this mindset because balances become a result of recorded transactions rather than something that can be manually mutated.
The same principles apply beyond wallets — immutable events, reconciliation, and strong invariants are valuable patterns for any system where correctness matters.
I especially like the focus on modeling the domain properly before scaling features. A reliable ledger is the foundation that makes payments, reporting, and compliance much easier to build on top of. Great work!