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.

See also: Coinbase Interview Prep

See also: Stripe Interview Prep

See also: LinkedIn Interview Prep

Scroll to Top