All system design challenges
#37 Double-Entry Bookkeeping

Wire Transfer API

An API that moves money between accounts, where the books must always balance and a retried request must never pay twice.

Asked at Google Revolut

What is being asked

Design a transfer API for a bank or payments company. The centre of the answer is double-entry bookkeeping and how to keep it correct under retries, crashes and concurrency.

Requirements

Functional

  • Transfer an amount from one account to another; query balance and history.
  • Transfers to other banks through external payment rails.

Non-functional

  • Money is never created or destroyed.
  • Exactly-once effect for each transfer request.
  • A complete audit trail; high availability.

Design

Client / partnerTransfer API(idempotency key)Transfer serviceLedger: append-onlyentriesAccounts + balancesOutboxExternal railsconnector
Boxes are services; rounded shapes are data stores and queues.
  1. Each request carries an idempotency key, stored with a unique constraint. A repeated key returns the original result.
  2. A transfer is recorded as two ledger entries in one database transaction: a debit on the source and an equal credit on the destination. The entries of a transaction always sum to zero.
  3. In the same transaction the source balance is checked and updated, with a row lock or a conditional update so it cannot go below zero.
  4. Ledger entries are never changed. A mistake is corrected by a new reversing entry.
  5. For an external transfer, the money first moves to a holding account, and a message in an outbox table (written in the same transaction) instructs the connector to call the other bank.

Key decisions and trade-offs

  • Ledger as truth, balance as cache. Balances can always be recomputed from entries; a nightly check that they agree catches bugs.
  • Lock ordering. Lock the two accounts in a fixed order, by id, so two opposite transfers cannot deadlock.
  • Transactional outbox avoids "database committed but message not sent". The connector must itself be idempotent, since messages are delivered at least once.
  • Integers, not floats. Store amounts as whole minor units with a currency.

Follow-up questions

  • How would you transfer between accounts held in different database shards?
  • What happens when the external bank does not reply?
  • How do you handle a transfer between two currencies?
  • How would you detect that the books no longer balance?
Log in to tick off the challenges you have worked through. Log in