Wire Transfer
Walk through a complete wire transfer design, from a naive single-database transaction to a production-grade distributed payment system that handles concurrent transfers, idempotent retries, and thousands of business payments per second.
What is a wire transfer API?
A wire transfer moves money from one bank account to another, directly and irrevocably. Unlike card payments, there is no chargeback network to catch mistakes. The engineering challenge sounds deceptively simple: debit one account, credit another, never do it twice. What makes it hard is that "never do it twice" and "always do both" must hold even when services crash mid-transfer, clients retry after a network timeout, and thousands of business payments target the same account simultaneously.
This question tests your grasp of distributed transactions, idempotency, optimistic vs pessimistic concurrency, and the design tradeoffs that separate a prototype from a system banks and payment processors actually trust.
TL;DR
Use a durable relational ledger and an idempotency record in the same database transaction. Serialize writes for each hot account with an account actor, execute the debit and credit with immutable ledger entries, and enforce a non-negative balance with a conditional update or serialized actor step.
For an external bank leg, use an orchestration-based saga with a durable log and the transfer ID as the bank's idempotency reference. Read status from a replica when it is acceptably fresh, but keep the primary database as the source of truth for money movement and reconcile any unknown external outcome instead of blindly retrying.
Scope and assumptions
The following are illustrative interview planning assumptions, not a specification for a bank or payment processor:
- Peak initiation volume is about 5,000 transfers per second; same-system initiation targets p99 latency under 500ms, while external-bank settlement can be asynchronous.
- A transfer has one currency and an exact decimal amount. The source must not overdraft, and the ledger records one debit and one credit for a completed local movement.
- The core path includes transfer creation, idempotent retries, durable status, concurrent-account protection, and an external settlement integration point.
- A local debit and credit are atomic within one database transaction. An external bank call is a saga step with an idempotent reference and may require manual reconciliation after an ambiguous timeout.
- Authentication, account ownership, FX, fraud/AML screening, and bank-specific settlement protocols are separate layers; their integration points are called out but not designed here.
- PostgreSQL, Redis, an account-actor router, a read replica, and the bank API are illustrative choices whose limits require capacity and failure testing.
Functional Requirements
Core Requirements
- Initiate a transfer from a source account to a destination account.
- Debit and credit atomically -- partial execution must never occur.
- Return a transfer ID for status tracking.
- Support transfer status queries and idempotent retries.
Below the Line (out of scope)
- FX conversion and multi-currency. Currency conversion touches regulatory compliance and interbank rate APIs -- a separate design. To add it later, we would insert a currency conversion step before the debit, locking the exchange rate atomically with the reservation.
- Fraud and compliance checks. OFAC screening, velocity limits, and AML checks belong in a separate fraud service invoked synchronously before we touch the ledger. We call out the integration point but do not design its internals.
- User authentication and account management. We assume the caller is authenticated and the account IDs are already verified. A real system would validate JWT claims and check account ownership before any ledger operation.
The hardest part in scope: Ensuring a transfer is applied exactly once even when the client retries (idempotency) and preventing concurrent debits from overdrafting the same account (concurrency control). Every design decision in this article traces back to these two constraints.
Non-Functional Requirements
Core Requirements
- Atomicity: A transfer must fully complete or fully roll back. No partial state where the source is debited but the destination is not credited. This eliminates any eventual-consistency approach for the debit-credit pair.
- Exactly-once execution: A transfer initiated with the same idempotency key must be applied at most once, regardless of how many times the client retries. This is the hardest constraint to satisfy in a distributed system.
- Latency: Transfer initiation returns a response within 500ms p99. The synchronous path (debit + credit + ledger write) must fit inside this window for same-bank transfers.
- Throughput: 5,000 transfers per second peak. Business payroll runs and batch settlements create short, intense write bursts that dwarf average load.
- Availability: 99.99% uptime (roughly 52 minutes of downtime per year). Availability is prioritised over strict read consistency for status checks, because transfer status only moves forward and a slightly stale "PENDING" read is harmless.
- Durability: Once a transfer is confirmed as COMPLETED, it must survive any single node failure including the database primary.
Below the Line
- Sub-100ms latency for cross-bank transfers (bank networks add their own 100ms+ overhead).
- Global multi-region active-active writes with sub-10ms cross-region replication lag.
Read/write ratio: Transfer initiation (write) and status polling (read) are roughly 1:3. This is not a read-heavy system. Status reads are cacheable and tolerate replica lag. The dominant challenge is write correctness, not read scale. Design decisions prioritise serialisability and durability over throughput optimisations like denormalisation or aggressive caching.
30-second answer / outline
- Validate the caller, accounts, amount, currency, and idempotency key before creating a
PENDINGtransfer. - Reserve the idempotency key and transfer record atomically in the primary database, then route account work by deterministic account ownership.
- Serialize hot-account operations with account actors and execute the debit, credit, and immutable ledger entries in one local ACID transaction.
- Orchestrate any external bank leg with a durable saga log and retry the same transfer reference; leave ambiguous outcomes pending for reconciliation.
- Serve status from a replica with a primary fallback, and monitor ledger invariants, actor queues, idempotency conflicts, saga age, and bank callbacks.
5-minute explanation
Start with the invariant: a transfer must not debit twice, must not overdraw the source, and must leave an auditable record of every balance change. A single PostgreSQL transaction is a correct starting point for a local transfer, but it needs an atomic idempotency record for client retries and concurrency control for competing debits.
At scale, account actors serialize operations for a given account and keep the database transaction short. The transaction still performs the durable debit, credit, transfer state transition, and two ledger inserts; the actor is a routing and contention-control mechanism, not a replacement for the database invariant. Deterministic account ordering avoids deadlocks when two transfers touch the same pair of accounts.
When settlement crosses a service or bank boundary, a distributed transaction cannot rely on 2PC. The Transfer Service writes a saga log before each step, calls the bank with the transfer ID as an idempotency reference, retries safely after timeouts, and escalates a bank-success/local-failure case to reconciliation because compensation may be impossible.
The design closes with durable status transitions, replica-lag handling, authentication and authorization, auditability, backpressure for hot accounts, and operational visibility into every non-terminal transfer.
45-minute interview approach
This is a time-boxed plan for answering the design question in an interview, not a claim that the article should be read in 45 minutes.
- 0β5 minutes β Clarify the contract: Confirm local versus cross-bank transfers, currencies, overdraft policy, synchronous response semantics, status states, idempotency-key scope, and reconciliation ownership.
- 5β10 minutes β Establish scale: Use the illustrative transfer rate, latency, read/write ratio, hot-account contention, ledger growth, and replica-lag assumptions. Separate transfer writes from status reads and external calls.
- 10β15 minutes β Define APIs and invariants: Walk through initiation, status, account history, idempotency behavior, exact amount representation, monotonic status transitions, and the no-double-debit/no-overdraft rules.
- 15β22 minutes β Draw the local transaction: Show the gateway, Transfer Service, primary database, account rows, transfer row, idempotency record, and two ledger entries. Explain the naive path before adding actors.
- 22β30 minutes β Deep dive on concurrency and idempotency: Compare row locks, optimistic locking, account actors, and virtual sub-accounts; then show the unique-key reservation and the retry/timeout behavior.
- 30β35 minutes β Deep dive on the ledger: Explain append-only entries, balance checkpoints, reconciliation, partitioning, and why a mutable balance alone is not an audit trail.
- 35β41 minutes β Reliability, security, and operations: Cover actor crash recovery, saga replay, ambiguous bank responses, replica lag, authorization, encryption, audit logs, rate limits, and alerts.
- 41β45 minutes β Trade-offs and close: Compare 2PC with orchestration, synchronous with asynchronous settlement, and primary reads with replicas. Recap the invariants and invite follow-ups.
Core Entities
- Account: A ledger account with an owner, a current balance checkpoint, and a currency. The primary entity that transfers debit and credit.
- Transfer: The record of a single money movement -- source account, destination account, amount, currency, status (PENDING, PROCESSING, COMPLETED, FAILED), and a link to the idempotency key that initiated it.
- LedgerEntry: An immutable debit or credit line associated with a transfer. Every transfer produces exactly two entries: one debit from the source, one credit to the destination. Never updated, only inserted.
- IdempotencyRecord: Maps a client-supplied idempotency key (plus caller identity) to a transfer ID and the original response body. Prevents duplicate execution on retry.
Schema details (column types, indexes, constraints) are deferred to the Data Model deep dive. The key relationship: Transfer links to two LedgerEntry rows, and the Account row holds a running balance checkpoint derived from the append-only ledger entries.
API Design
Start with one endpoint per functional requirement, then evolve where the naive shape breaks down.
FR 1 -- Initiate a transfer (naive shape):
POST /transfers
Body: { source_account_id, destination_account_id, amount, currency }
Response: { transfer_id, status }
This works but has a fatal flaw: if the client's TCP connection drops after the debit executes but before the response arrives, the client cannot know whether the transfer happened. A retry would debit the source a second time.
FR 1 -- Initiate a transfer (evolved shape with idempotency):
POST /transfers
Headers: Idempotency-Key: <client-generated UUID>
Body: { source_account_id, destination_account_id, amount, currency }
Response: { transfer_id, status, created_at }
The Idempotency-Key is client-generated (a UUID the caller creates before the first attempt and reuses on every retry). If the server has already seen this key, it returns the original response without re-executing. This is a common pattern for payment APIs.
FR 2 -- Query transfer status:
GET /transfers/{transfer_id}
Response: { transfer_id, source_account_id, destination_account_id, amount, currency, status, created_at, completed_at }
GET because this is a pure read with no side effects. Both created_at and completed_at are returned so clients can distinguish "still processing" from "timed out and stuck". Status is one of PENDING, PROCESSING, COMPLETED, FAILED.
FR 3 -- List transfers for an account:
GET /accounts/{account_id}/transfers?limit=50&cursor=<opaque>
Response: { transfers: [...], next_cursor: "..." }
Cursor-based pagination because transfers are time-ordered and the dataset grows without bound. Offset pagination breaks when new transfers land between page fetches, producing duplicate or missing rows at page boundaries.
Authentication is out of scope for this design, but in production every endpoint requires a bearer token. The Transfer Service validates that the authenticated caller owns source_account_id before touching the ledger. Accepting arbitrary account IDs without ownership verification is an authorization vulnerability.
High-Level Design
We build the system incrementally, one requirement at a time, adding components only when the next requirement demands them.
1. Initiate a transfer (naive path)
The simplest correct design: one server, one database, one transaction wrapping both the debit and credit.
Components:
- Client: Any caller initiating a transfer via the REST API.
- Transfer Service: Validates the request, opens a DB transaction, debits source, credits destination, writes the transfer record.
- Accounts DB: Single PostgreSQL instance. Stores account balances and the transfers table. ACID transactions give us atomicity for free.
Request walkthrough:
- Client sends
POST /transferswith source, destination, amount, and idempotency key. - Transfer Service opens a database transaction.
- Read source account balance and check for sufficient funds.
UPDATE accounts SET balance = balance - amount WHERE id = source_account_id.UPDATE accounts SET balance = balance + amount WHERE id = destination_account_id.INSERT INTO transfers (...)with status = COMPLETED.- Commit. Return
{ transfer_id, status: COMPLETED }to client.
This is the happy path. The single-transaction approach is correct and simple. Problems emerge when we add the idempotency and concurrency requirements.
2. Idempotency -- handling retries safely
Clients retry after network timeouts. Without an idempotency check, each retry debits the source again.
The failure scenario: Client sends POST /transfers with key abc-123. The transfer succeeds, but TCP drops before the response arrives. Client retries with the same key. Without protection, the source account is debited twice.
The fix is an idempotency table checked before executing any transfer logic. The check must be atomic with the execution -- more on that in the deep dives.
Components added:
- IdempotencyRecord table: Maps
(idempotency_key, caller_id)to(transfer_id, response_body). Unique index on(idempotency_key, caller_id). Added to the same PostgreSQL instance for now.
Updated request walkthrough:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.