Low-Level Design: Banking System — Accounts, Transactions, Transfers, and Fraud Monitoring

Core Entities

Customer: customer_id, first_name, last_name, email, phone, date_of_birth, ssn_hash, kyc_status (PENDING, VERIFIED, REJECTED), address, created_at. Account: account_id, customer_id, account_number (unique, generated), type (CHECKING, SAVINGS, CREDIT, LOAN), status (ACTIVE, FROZEN, CLOSED), balance (DECIMAL(18,2)), currency, interest_rate, opened_at, closed_at. Transaction: transaction_id, account_id, type (DEBIT, CREDIT, TRANSFER_IN, TRANSFER_OUT, FEE, INTEREST, REVERSAL), amount, balance_after, description, reference_id (idempotency key), status (PENDING, COMPLETED, FAILED, REVERSED), initiated_at, completed_at. Transfer: transfer_id, from_account_id, to_account_id, amount, currency, status (INITIATED, DEBITED, COMPLETED, FAILED, REVERSED), initiated_at, completed_at. Card: card_id, account_id, card_number_hash, last_four, expiry_date, cvv_hash, status (ACTIVE, BLOCKED, EXPIRED), daily_limit, monthly_limit. FraudAlert: alert_id, account_id, transaction_id, type, risk_score, status (OPEN, RESOLVED, FALSE_POSITIVE), triggered_at.

ACID-Safe Money Transfer

class TransferService:
    def initiate_transfer(self, from_id: int, to_id: int,
                          amount: Decimal, ref_id: str) -> Transfer:
        # Idempotency check
        existing = self.repo.get_transfer_by_ref(ref_id)
        if existing:
            return existing

        with self.db.transaction(isolation='SERIALIZABLE'):
            # Lock accounts in consistent order (prevent deadlock)
            ids = sorted([from_id, to_id])
            accts = {a.account_id: a for a in
                     self.repo.get_accounts_for_update(ids)}

            src = accts[from_id]
            dst = accts[to_id]

            if src.status != AccountStatus.ACTIVE:
                raise AccountNotActiveError()
            if src.balance < amount:
                raise InsufficientFundsError(
                    f'Balance: {src.balance}, requested: {amount}'
                )

            src.balance -= amount
            dst.balance += amount

            transfer = Transfer(from_account_id=from_id,
                                to_account_id=to_id, amount=amount,
                                reference_id=ref_id,
                                status=TransferStatus.COMPLETED)

            self.repo.save_transaction(from_id, -amount, 'TRANSFER_OUT',
                                       src.balance, ref_id)
            self.repo.save_transaction(to_id, +amount, 'TRANSFER_IN',
                                       dst.balance, ref_id)
            self.repo.save(src); self.repo.save(dst)
            self.repo.save(transfer)
            return transfer

Lock ordering: always acquire locks in ascending account_id order. Without this, two concurrent transfers (A→B and B→A) would each lock their source account and wait for the other — classic deadlock. Sorted lock acquisition breaks the cycle. Serializable isolation: prevents phantom reads and ensures the balance check and deduction are atomic. For high-throughput scenarios, READ COMMITTED + application-level optimistic locking (check-and-set on balance with version number) can be used instead.

Transaction History and Balance Consistency

Balance invariant: account.balance must always equal the sum of all completed transaction amounts for that account. Violation indicates a bug or data corruption. Enforcement: never update balance directly except within a transaction that also inserts a Transaction record. Use a database trigger or application-level constraint. Periodic reconciliation: nightly job recomputes balance from transaction history and compares to the stored balance. Alerts on discrepancy > $0.00. Transaction ledger: the Transaction table is append-only. Never delete or modify transaction records — they are the immutable audit log. Reversals are recorded as new REVERSAL transactions (positive amount for the original debit). Statement generation: sum transactions in a date range, grouped by type. Pre-computed monthly statements are stored as PDF/JSON in object storage (S3) — generated once, never recomputed. Interest calculation: for savings accounts, daily interest accrual job: INTEREST = balance * daily_rate. Recorded as a CREDIT transaction. Compounded daily.

Fraud Monitoring

Real-time fraud rules evaluated on every transaction: velocity check: > 5 transactions in 10 minutes from the same account → alert. Geographic anomaly: transaction in a country not visited in the last 6 months → alert. Amount anomaly: transaction > 3x the 30-day average transaction amount → alert. New device/IP: first transaction from this device or IP → require 2FA challenge. Card not present (CNP) high-value transaction: > $500 online purchase → SMS OTP. Rules engine: each rule is a predicate evaluated against the transaction + account context (rolling aggregates from Redis). Risk score: sum of triggered rule weights (e.g., velocity=20, geo_anomaly=30, amount_anomaly=15). Score > 50: hold transaction for review. Score > 80: block immediately and freeze card. Account freeze: set account.status = FROZEN. All debit/transfer attempts are rejected. Customer receives notification with a phone number to call to resolve. ML model: gradient boosted trees trained on labeled fraud vs. legitimate transactions. Runs alongside the rules engine. Both scores are combined (rules-based score * 0.6 + ML score * 0.4). Model retrained weekly on labeled outcomes.


{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent deadlocks in concurrent bank transfers?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Always acquire row locks on accounts in a consistent global order (e.g., sorted by account_id). If transfer A locks account 1 then account 2, and transfer B also locks account 1 then account 2 (not 2 then 1), they cannot deadlock — one will block and wait while the other completes. Without consistent ordering, transfer A→B and B→A could each lock their source and wait for the other's target, forming a circular wait.”}},{“@type”:”Question”,”name”:”Why use SERIALIZABLE isolation for bank transfers instead of READ COMMITTED?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”READ COMMITTED allows a transaction to see committed changes from other transactions mid-execution. In a balance check + deduct operation, another transaction could modify the balance between the check and the deduct, causing a race condition (e.g., two simultaneous withdrawals both pass the balance check but together overdraft the account). SERIALIZABLE prevents this by ensuring that each transaction sees a consistent snapshot, as if transactions executed serially.”}},{“@type”:”Question”,”name”:”Why is the transaction ledger append-only?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”An append-only ledger is the immutable audit trail of all account activity. Every debit and credit is a permanent record that cannot be deleted or modified. This is required for regulatory compliance (banks must retain 7+ years of transaction history), fraud investigation (reconstruct exactly what happened and when), balance reconciliation (the balance should always equal the sum of all transaction records), and dispute resolution. Reversals are recorded as new transactions, not edits to old ones.”}},{“@type”:”Question”,”name”:”How does velocity-based fraud detection work in real time?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use Redis counters with sliding time windows. On each transaction: INCR txn_count:{account_id}:{minute_bucket} with TTL = 10 minutes. Check if the count exceeds the threshold (e.g., 5 in 10 minutes). This is O(1) and happens before the transaction is processed. If the threshold is exceeded, hold the transaction for manual review or require 2FA. The sliding window is approximated using two adjacent minute buckets weighted by the elapsed time within the current minute.”}},{“@type”:”Question”,”name”:”What is the difference between a hard block and a soft decline in fraud prevention?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A hard block immediately rejects the transaction and may freeze the account or card — used for high-risk scores or confirmed fraud patterns. A soft decline applies a challenge (SMS OTP, biometric confirmation) before allowing the transaction — used for medium-risk signals where the transaction might be legitimate but needs verification. Soft declines preserve the user experience for legitimate transactions while still providing a friction barrier against fraud.”}}]}

See also: Coinbase Interview Prep

See also: Stripe Interview Prep

See also: LinkedIn Interview Prep

Scroll to Top