Designing a Payment / Wallet System

A system design interview guide to moving and tracking money correctly, where the defining requirement is that the system must never lose, invent, or double-count a single unit of currency.

A payment or wallet system is the textbook case where correctness outranks every other quality. Most services can tolerate a dropped request or a stale read; a money system cannot tolerate a balance that is off by a cent. That single constraint reshapes the whole design. Instead of optimizing for raw availability, you lean toward strong consistency on the money path; instead of mutating balances in place, you record an immutable, balanced trail of entries; and instead of hoping retries are harmless, you make them harmless with idempotency. This guide develops the core of such a system: the double-entry ledger, the locking that prevents double-spends, idempotent payment requests, sagas for multi-step flows, and the reconciliation that proves it all adds up.

Contents

  1. Correctness Above All
  2. The Double-Entry Ledger
  3. Append-Only & Balances
  4. Preventing Double-Spend
  5. Idempotent Payment Requests
  6. Multi-Step Payments & Sagas
  7. Reconciliation & Auditing
  8. Consistency Over Availability
  9. Summary

1. Correctness Above All

The first thing to say in an interview about a payment system is that its success criterion is unusual. For most systems we negotiate trade-offs — a little staleness for a lot of availability, some duplicates for simplicity. For money there is no such negotiation on the core invariant: every unit of currency that enters the system must be accounted for at every instant, and none may be created or destroyed except through an explicit, recorded transaction. A balance that is silently wrong is not a bug to be triaged later; it is a failure of the system's entire reason for existing.

This requirement is what justifies design choices that would look over-engineered elsewhere. We use transactions and locks where another system might use eventual consistency, we keep an immutable history where another system might overwrite a value, and we run continuous reconciliation where another system might trust its own writes. Each of these costs throughput or latency, and each is worth it because the alternative is losing or duplicating money. Stating this priority up front tells the interviewer you understand the domain rather than reflexively reaching for the scaling playbook.

A sharp framing: in a payment system, correctness is the feature and everything else — throughput, latency, even availability — is subordinate to it. Decisions that trade a little speed for certainty about the money are almost always right.

2. The Double-Entry Ledger

The foundational model for tracking money correctly is the double-entry ledger, an idea borrowed from centuries of accounting precisely because it makes errors visible. The rule is simple: money is never created or destroyed, only moved, so every transaction touches at least two accounts — it debits one and credits another by the same amount. A transfer of ten units from Alice to Bob is recorded as a debit of ten on Alice's account and a credit of ten on Bob's, and the two must balance.

Double-entry ledger transfer
A single transfer is recorded as two balanced entries: a debit on the sender's account and a matching credit on the receiver's, for the same amount. The invariant that the sum of debits equals the sum of credits must hold for every transaction.

The power of this model is that it gives you a built-in integrity check. At any moment, across the whole system, the sum of all debits must equal the sum of all credits — money is conserved by construction. If that equation ever fails to hold, you know with certainty that something is wrong, and often where, long before a customer notices. A system that simply stored a single mutable balance per account has no such check: a stray write corrupts the number with no trace and no alarm.

# one transfer = two balanced entries
transaction(id=771):
  entry(account="alice", amount=-10)   # debit
  entry(account="bob",   amount=+10)   # credit
# invariant: sum of all entries in a txn == 0

3. Append-Only & Balances

Ledger entries are append-only and immutable: once written, an entry is never updated or deleted. A mistake is corrected not by editing the past but by writing a new, compensating entry that reverses it. This is a deliberate constraint, and it buys two things that are hard to overstate for a money system. First, a complete audit trail: every change to every balance is a row you can point to, with a cause and a timestamp, which is exactly what auditors and regulators require. Second, the elimination of an entire class of bugs — there is no in-place update to race on, no value to silently clobber.

That raises an obvious question: if you only ever append entries, how do you know an account's current balance? There are two answers, and real systems often use both. You can derive the balance by summing all of an account's entries — always correct, but slow once the history is large. Or you can materialize the balance, keeping a running total that is updated as part of the same transaction that appends the entry. The materialized balance is fast to read, and the append-only entries remain the source of truth you can always recompute against to catch drift.

ApproachHow balance is knownTrade-off
DerivedSum all entries for the account on demand.Always correct; slow as history grows.
MaterializedKeep a running total, updated with each entry.Fast reads; must update atomically with the entry.
BothMaterialize for speed; derive periodically to verify.Fast and self-checking; a little extra work.

4. Preventing Double-Spend

The classic money bug is the double-spend: an account with ten units somehow funds two payments of ten because both requests read the balance, both saw enough funds, and both proceeded before either had deducted anything. This is a read-modify-write race, and the only robust cure is to serialize the conflicting operations so they cannot both see the same stale balance.

Two mechanisms handle this, both leaning on the database. Row-level locking (pessimistic) takes a lock on the account row before reading its balance, inside a transaction, so a concurrent payment must wait its turn — by the time it reads, the first deduction is already reflected. Optimistic concurrency takes no lock but versions the row: each update is conditioned on the version it read, and if another writer got there first the version no longer matches and the update fails, prompting a retry. Pessimistic locking suits hot accounts with frequent contention; optimistic suits the common case where conflicts are rare and you would rather not hold locks.

# pessimistic: lock the row, then check and deduct atomically
begin()
  row = select balance from accounts where id=:a for update  # row lock
  if row.balance < amount: rollback(); reject("insufficient funds")
  update accounts set balance = balance - :amount where id=:a
  ledger.append(debit=:a, credit=:b, amount=:amount)
commit()                                # balance + entry commit together
Whichever you choose, the non-negotiable part is that the balance check, the deduction, and the ledger entry all happen inside one database transaction. If they can commit separately, a crash or race can leave money deducted with no entry, or an entry with no deduction — exactly the corruption the design exists to prevent.

5. Idempotent Payment Requests

Payments travel over networks, and networks drop responses, so a client that does not hear back will retry — and a retried payment must never charge twice. The defense is the same idempotency-key pattern used throughout reliable systems: the client generates a unique key for each logical payment and reuses it on every retry of that payment. The payment service records each key's outcome and, on seeing a key it has already completed, returns the stored result instead of charging again.

Idempotent payment request dedup
The client sends a payment with an idempotency key; the payment API checks the dedup store. The first time the key is seen, it charges once and records the result; on a retry with the same key, it returns the stored result without charging again.
function pay(request, idempotency_key):
  prior = dedup.get(idempotency_key)
  if prior and prior.status == "COMPLETED":
    return prior.response               # retry: do not charge again
  dedup.claim(idempotency_key)          # unique constraint: one winner
  result = charge(request)              # the side effect, run once
  dedup.complete(idempotency_key, result)
  return result

This is the bridge between the payment system and general idempotency design: the money path is simply the highest-stakes place to apply it. A duplicate notification is an annoyance; a duplicate charge is a refund, a support ticket, and a dent in trust, so the dedup store here is not optional polish but part of the correctness guarantee.

6. Multi-Step Payments & Sagas

Real payments rarely complete in a single local transaction. A purchase might need to reserve funds in a wallet, charge an external processor, and confirm an order — three steps that often span separate services with their own databases. You cannot wrap them in one database transaction, so you need a way to keep the overall flow consistent even when a later step fails after earlier ones succeeded. The standard answer is the saga: a sequence of local transactions where each step has a matching compensating action that undoes it.

Payment saga with compensation
A multi-step payment runs as a saga: reserve funds, charge, then confirm. If any step fails, the saga executes compensating actions in reverse — releasing the reservation, refunding the charge — to return the system to a consistent state.

The flow runs forward step by step: reserve funds, then charge, then confirm. If every step succeeds, the payment is done. If a step fails — say the charge is declined after funds were reserved — the saga runs the compensations for the steps that did complete, in reverse order, releasing the reservation so no money is left stranded. The result is not the atomicity of a single transaction, but a disciplined eventual consistency: the system always ends in either fully-applied or fully-undone, never half-done. Crucially, each compensation must itself be idempotent, since the orchestrator may retry it.

Forward stepCompensating action
Reserve fundsRelease the reservation.
Charge the processorRefund the charge.
Confirm the orderCancel the order.

7. Reconciliation & Auditing

Even a careful system must continually prove to itself that it is correct, because bugs, partial failures, and discrepancies with external processors are inevitable over time. Reconciliation is the routine that does this proving. Internally, it re-derives balances from the append-only ledger and checks them against the materialized balances, and it verifies the global invariant that debits equal credits. Externally, it compares the system's records against statements from banks and payment processors — the money you think you moved against the money they say moved — and flags any mismatch for investigation.

This is where the append-only, double-entry design pays off most. Because the ledger is an immutable, balanced history, reconciliation has a trustworthy source of truth to check against, and any drift surfaces as a concrete discrepancy with a clear location rather than a vague sense that the numbers feel off. The same immutable trail is the audit record regulators require: a complete, tamper-evident account of every movement of money, queryable long after the fact.

# reconciliation: the system checks itself
for account in accounts:
  derived = sum(ledger.entries(account))      # recompute from source of truth
  assert derived == account.materialized_balance
assert sum(all debits) == sum(all credits)    # global money conservation
diff = compare(internal_records, processor_statement)  # external check

8. Consistency Over Availability

Every distributed system eventually faces the choice the CAP theorem describes: when the network partitions, you can stay consistent or stay available, but not both. Most consumer systems lean toward availability — better to serve a slightly stale feed than an error page. The money path makes the opposite call. If the system cannot be sure a payment will be recorded correctly and exactly once, it should refuse the operation rather than risk corrupting the ledger. A declined payment that the user can retry is recoverable; a double charge or a lost transfer is not.

In practice this means the core money path is built on strongly consistent storage and synchronous, transactional writes, accepting the latency and the occasional unavailability that come with it. It does not mean the entire product must be consistency-first — read-heavy, non-monetary surfaces like transaction history or analytics can be served from replicas and caches, where staleness is harmless. The art is drawing the line: strong consistency for anything that moves or guards money, relaxed consistency for everything that merely reports on it.

A useful boundary to articulate: be strict on the write path that changes balances, and relaxed on the read paths that only describe them. You buy correctness exactly where it is non-negotiable and pay for it nowhere else.

9. Summary

A payment system is, above all, a correctness machine — a set of choices that together make it impossible to quietly lose or double-count money:

ConcernMechanism
What is the overriding requirement?Correctness — never lose, invent, or double-count money — above throughput or availability.
How is money tracked?A double-entry ledger: every transaction is balanced debits and credits.
How is history kept honest?Append-only, immutable entries; balances derived or materialized from them.
How is double-spend prevented?One DB transaction with row-level locking or optimistic versioning on the balance.
How are retried payments made safe?Idempotency keys with a dedup store, so a retry never charges twice.
How are multi-step payments coordinated?A saga with compensating actions to roll back on failure.
How do we know it all adds up?Reconciliation against the ledger and external statements; immutable audit trail.
What do we sacrifice for correctness?Consistency over availability on the money path — refuse rather than corrupt.
The recurring theme: a payment system spends complexity to buy certainty. Double-entry makes errors visible, immutability removes a class of bugs, locking serializes the dangerous writes, idempotency tames retries, sagas keep distributed flows whole, and reconciliation continuously proves the money is all there.