Fintech System Design — Patterns That Handle Money
How Financial Systems Are Different
| Aspect | Regular App | Financial System |
|---|---|---|
| Data loss | Annoying | Catastrophic (lost money) |
| Consistency | Eventual is fine | Strong consistency required |
| Precision | float/double OK | BigDecimal or integer (paise/cents) |
| Audit | Nice to have | Legally required |
| Idempotency | Good practice | Mandatory |
| Downtime | Users complain | Regulators 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 modificationsSERIALIZABLEisolation — 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
- Database: PostgreSQL for ACID. Ledger table uses double-entry.
- Consistency: SERIALIZABLE isolation for balance operations.
- Idempotency: Every API call requires an idempotency key.
- Async: Notifications and fraud checks are async (don’t block payment).
- Reconciliation: Daily batch job compares our ledger with bank statements.
- Caching: Cache user profiles, NOT balances (staleness = lost money).
- 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