Career OS

Fintech System Design — Patterns That Handle Money

How Financial Systems Are Different

AspectRegular AppFinancial System
Data lossAnnoyingCatastrophic (lost money)
ConsistencyEventual is fineStrong consistency required
Precisionfloat/double OKBigDecimal or integer (paise/cents)
AuditNice to haveLegally required
IdempotencyGood practiceMandatory
DowntimeUsers complainRegulators investigate

Pattern 1: Double-Entry Ledger

The foundation of every financial system. Every transaction creates two entries that must balance.

Transaction: Darshan pays Rs. 500 to merchant

Ledger entries:
  | Account          | Debit | Credit |
  |------------------|-------|--------|
  | Darshan's wallet |  500  |        |
  | Merchant wallet  |       |   500  |
  | TOTAL            |  500  |   500  | ← Must always balance
@Entity
@Table(name = "ledger_entries")
public class LedgerEntry {
    @Id
    private Long id;
    
    private String transactionId;    // Links debit + credit entries
    private Long accountId;
    private BigDecimal amount;       // Always positive
    
    @Enumerated(EnumType.STRING)
    private EntryType type;          // DEBIT or CREDIT
    
    private Instant createdAt;
    // No update/delete methods — ledger is append-only
}

Rule: If your ledger entries don’t sum to zero, you have a bug that’s losing or creating money.

Pattern 2: Idempotent Payment Processing

@Service
public class PaymentService {
    
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public PaymentResult processPayment(String idempotencyKey, PaymentRequest request) {
        // Step 1: Check if already processed
        Optional<Payment> existing = paymentRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return toResult(existing.get()); // Return previous result
        }
        
        // Step 2: Validate
        Account sender = accountRepo.findByIdForUpdate(request.senderId()); // SELECT FOR UPDATE
        if (sender.getBalance().compareTo(request.amount()) < 0) {
            throw new InsufficientBalanceException();
        }
        
        // Step 3: Execute (double-entry)
        sender.debit(request.amount());
        Account receiver = accountRepo.findByIdForUpdate(request.receiverId());
        receiver.credit(request.amount());
        
        // Step 4: Record
        Payment payment = Payment.create(idempotencyKey, request);
        paymentRepo.save(payment);
        
        // Step 5: Publish event (async notification, not blocking)
        eventPublisher.publish(new PaymentCompletedEvent(payment));
        
        return toResult(payment);
    }
}

Key details:

  • SELECT FOR UPDATE — locks the row to prevent concurrent modifications
  • SERIALIZABLE isolation — strongest guarantee
  • Idempotency key checked FIRST — duplicate requests return cached result
  • Event published AFTER commit — don’t notify before money moves

Pattern 3: Saga Pattern for Distributed Transactions

When a payment involves multiple services (wallet + rewards + notifications), you can’t use a single database transaction.

Saga: Sequence of local transactions with compensating actions.

Step 1: Debit wallet         → Success → Continue
Step 2: Credit merchant      → Success → Continue  
Step 3: Award loyalty points → FAILURE  
  → Compensate Step 2: Reverse merchant credit
  → Compensate Step 1: Reverse wallet debit
  → Return failure to user
public class PaymentSaga {
    
    public void execute(PaymentRequest request) {
        try {
            walletService.debit(request);           // Step 1
            merchantService.credit(request);         // Step 2
            loyaltyService.awardPoints(request);     // Step 3
        } catch (LoyaltyServiceException e) {
            // Compensate in reverse order
            merchantService.reverseCredit(request);  // Undo Step 2
            walletService.reverseDebit(request);     // Undo Step 1
            throw new PaymentFailedException("Loyalty service failed", e);
        }
    }
}

Pattern 4: Event Sourcing for Financial Audit

Instead of storing current state, store every event that changed the state.

Traditional:  Account { balance: 5000 }  ← How did we get here?

Event sourced:
  Event 1: AccountCreated { amount: 0 }
  Event 2: Deposited { amount: 10000 }
  Event 3: PaymentMade { amount: -3000 }
  Event 4: RefundReceived { amount: 500 }
  Event 5: PaymentMade { amount: -2500 }
  Current balance: 0 + 10000 - 3000 + 500 - 2500 = 5000 ✓

Why fintech loves this:

  • Complete audit trail (regulators require it)
  • Can rebuild state at any point in time (“what was the balance on March 15?”)
  • Can replay events to find bugs
  • No data loss — events are append-only

Pattern 5: Reconciliation

Every financial system needs reconciliation — comparing your records with external systems to find discrepancies.

Your DB says: 1,547 transactions today, total Rs. 23,45,600
Bank says:    1,546 transactions today, total Rs. 23,42,100
              ─────
              1 missing transaction, Rs. 3,500 discrepancy
@Scheduled(cron = "0 0 2 * * *") // Run at 2 AM daily
public void dailyReconciliation() {
    List<Payment> ourRecords = paymentRepo.findByDate(yesterday());
    List<BankTransaction> bankRecords = bankApi.getTransactions(yesterday());
    
    ReconciliationReport report = reconciler.compare(ourRecords, bankRecords);
    
    if (report.hasDiscrepancies()) {
        alertService.notifyOps(report);
        // Don't auto-fix — humans investigate discrepancies
    }
}

System Design Interview: “Design a Digital Wallet”

Your answer structure:

Requirements

  • Users can add money (bank transfer/UPI)
  • Users can pay merchants
  • Users can transfer to other users
  • Transaction history
  • 10M users, 1M transactions/day

High-Level Architecture

Mobile App → API Gateway → Auth Service
                         → Wallet Service → PostgreSQL (ledger)
                         → Payment Service → Bank API

                         Message Queue (Kafka)

                         Notification Service
                         Reconciliation Service
                         Fraud Detection Service

In one picture — the synchronous path stays on the left and the slow work hangs off a queue so it never blocks the payment:

flowchart TD
    APP["Mobile app"] --> GW["API gateway"]
    GW --> AUTH["Auth service"]
    GW --> WAL["Wallet service"]
    GW --> PAY["Payment service"]
    WAL --> PG[("PostgreSQL<br/>double-entry ledger")]
    PAY --> BANK["Bank API"]
    PAY --> MQ["Message queue<br/>Kafka"]
    WAL --> MQ
    MQ --> NOTIF["Notification service"]
    MQ --> RECON["Reconciliation service"]
    MQ --> FRAUD["Fraud detection service"]

The wallet and payment services talk to Postgres and the bank on the hot path. Everything async — notifications, reconciliation, fraud scoring — subscribes to the queue, so a slow fraud check can never delay money moving.

Key Design Decisions

  1. Database: PostgreSQL for ACID. Ledger table uses double-entry.
  2. Consistency: SERIALIZABLE isolation for balance operations.
  3. Idempotency: Every API call requires an idempotency key.
  4. Async: Notifications and fraud checks are async (don’t block payment).
  5. Reconciliation: Daily batch job compares our ledger with bank statements.
  6. Caching: Cache user profiles, NOT balances (staleness = lost money).
  7. Rate limiting: 10 payments/minute per user.

Scale Considerations

  • Read replicas for transaction history queries
  • Partition ledger by user_id for write scaling
  • Kafka for decoupling services (wallet doesn’t wait for notification)
  • Redis for rate limiting (distributed, fast)

This answer shows you understand financial systems deeply. That’s what gets you hired.

Module done? Add it to today’s tracker

Saves your progress on this device.