Loan Origination System Design
Design a scalable enterprise Loan Origination System — covering application intake, credit bureau integration, underwriting decisioning engine, document collection, KYC/AML compliance, approval workflow, loan booking, and disbursement with end-to-end audit trail.
1-Hour Interview Roadmap
| Time | Topic |
|---|---|
| 0 – 5 min | Requirements clarification |
| 5 – 10 min | Capacity estimation |
| 10 – 18 min | High-level architecture + core services |
| 18 – 28 min | Application lifecycle — intake through decisioning |
| 28 – 36 min | Underwriting decisioning engine + credit bureau integration |
| 36 – 43 min | KYC/AML compliance + document collection |
| 43 – 50 min | Approval workflow + loan booking + disbursement |
| 50 – 56 min | Database design + API design |
| 56 – 60 min | Trade-offs + common interview mistakes |
What Are We Building?
An enterprise Loan Origination System (LOS) that manages the complete lifecycle of a loan application — from the moment an applicant applies through identity verification, credit assessment, underwriting decisioning, document collection, approval, loan booking, and funds disbursement.
A loan origination is not a single API call. It is a multi-day, multi-party regulated workflow that touches credit bureaus, fraud detection systems, compliance engines, document management, banking cores, and payment rails — all under strict regulatory timelines (ECOA requires credit decisions within 30 days; TILA requires disclosures at specific steps).
Scale reference: SoFi originates ~$4 billion in loans per quarter. LendingClub processes ~$1–2 billion per quarter. A mid-tier lender processes 5,000–50,000 loan applications per month. Design for 20,000 applications per month (~700/day, ~30/hour average) across personal loans, auto loans, and mortgages.
Key unique challenges:
- Regulatory compliance — ECOA (credit decisions within 30 days), TILA (disclosure timing), FCRA (credit pull consent), BSA/AML (identity verification) — violations carry civil and criminal liability
- Third-party orchestration — credit bureau pulls, identity verification, income verification, and fraud checks are external; each can fail, timeout, or return stale data
- Decisioning consistency — two identical applicants submitted 1 second apart must receive the same decision; rules must be version-controlled and auditable
- Idempotency — a credit bureau pull costs money and creates a hard inquiry on the applicant's credit file; retries must not trigger duplicate pulls
- Concurrent modification — underwriter and automated system must not simultaneously update the same application; optimistic locking required
Step 1 — Requirements
Functional Requirements
| # | Requirement |
|---|---|
| 1 | Applicant submits a loan application with personal, financial, and employment information |
| 2 | System verifies applicant identity via KYC (Know Your Customer) with government ID check |
| 3 | System performs AML (Anti-Money Laundering) screening against OFAC and sanctions lists |
| 4 | System pulls credit report from one or more bureaus (Equifax, Experian, TransUnion) |
| 5 | System runs automated underwriting decisioning: approve, decline, or refer to manual review |
| 6 | Underwriter can review referred applications, override automated decisions, and add conditions |
| 7 | System collects required documents (pay stubs, bank statements, ID) and validates completeness |
| 8 | Approved applicant receives binding loan offer with terms, rate, and TILA disclosures |
| 9 | Applicant can accept or reject the loan offer within the offer validity window |
| 10 | Accepted loan is booked in the core banking system and funds disbursed to applicant |
| 11 | System generates all required regulatory disclosures (Adverse Action Notice for declines) |
| 12 | Full audit trail of every decision, document, and action for regulatory examination |
Non-Functional Requirements
| # | Requirement |
|---|---|
| 1 | Application submission responds within 3 seconds (p99) |
| 2 | Automated underwriting decision returned within 30 seconds (p99 — bureau pulls included) |
| 3 | Zero application loss — no submitted application can be silently dropped |
| 4 | Credit bureau pulls must be idempotent — no duplicate hard inquiries on retries |
| 5 | All decisioning rules must be versioned and auditable — can replay any past decision |
| 6 | FCRA compliance — applicant consent recorded before any credit pull |
| 7 | Adverse Action Notice generated within 30 days of a decline decision |
| 8 | Data encryption — PII encrypted at rest (AES-256) and in transit (TLS 1.3) |
| 9 | Audit trail retained for 7 years minimum per federal regulations |
| 10 | High availability — 99.9% uptime for application intake API |
Out of Scope
- Loan servicing (post-disbursement payments, statements, payoffs)
- Collections and delinquency management
- Secondary market loan sales and securitization
- Rate and product pricing engine
- Dealer/broker origination portals (direct-to-consumer only)
Step 2 — Capacity Estimation
Traffic Estimates
Monthly applications: 20,000
Daily applications: 20,000 / 30 = ~667/day
Average rate: 667 / 86,400 = ~0.008 apps/sec (very low steady state)
Peak rate (campaign launch): 10× = ~0.08 apps/sec = ~5 apps/minute
Workflow steps per application (avg):
Bureau pulls: 3 (one per bureau)
KYC/AML checks: 2–4
Document uploads: ~6 docs per application
Underwriting events: ~15 state transitions + events
Total actions per app: ~30
Daily workflow actions: 667 × 30 = ~20,000 actions/day
Peak (campaign day): 200,000 actions/day
Credit bureau API calls: 667 × 3 = ~2,000 bureau pulls/day
Peak (campaign): ~20,000 pulls/day
Cost implication: Each pull ~$0.50–$1.50 (must not duplicate)
Storage Estimates
Per application record:
Application data: ~3 KB
Credit bureau reports: ~15 KB each × 3 bureaus = 45 KB
Workflow events (30 avg): ~300 bytes × 30 = 9 KB
Decisioning output: ~5 KB
Total per application: ~62 KB (metadata only)
Document storage:
Avg 6 documents × 3 MB each = 18 MB per application
Monthly: 20,000 × 18 MB = ~360 GB/month
7-year retention: 360 GB × 12 × 7 = ~30 TB total
Annual metadata storage:
20,000 apps/month × 62 KB × 12 = ~15 GB/year (trivial for PostgreSQL)
Key Insights
- Volume is modest, regulatory complexity is high — 700/day is low throughput; the challenge is compliance, not scale
- Bureau pulls are expensive — idempotency is a cost and legal requirement, not just a reliability concern
- Documents dominate storage — 30 TB over 7 years; requires object storage with compliant retention
- Peak factor matters — marketing campaigns and rate drops create 10× spikes; queue-based architecture absorbs bursts
- Decisioning audit is permanent — every rule version that produced every decision must be retained indefinitely for regulatory replay
Step 3 — High-Level Architecture
flowchart TD
Applicant["Applicant\n(Web / Mobile / Partner API)"]
AG["API Gateway\nAuth + Rate Limiting + TLS"]
AS["Application Service\n(Orchestrator + state machine)"]
KYC["KYC/AML Service\n(Identity + sanctions screening)"]
CB["Credit Bureau Service\n(Bureau pull + dedup)"]
UW["Underwriting Engine\n(Automated decisioning)"]
DCS["Document Collection Service\n(Upload + validation + completeness)"]
OFS["Offer Service\n(Loan offer + TILA disclosures)"]
BKS["Booking Service\n(Core banking integration)"]
DIS["Disbursement Service\n(ACH / wire disbursement)"]
NS["Notification Service\n(Applicant + underwriter alerts)"]
AuditS["Audit Service\n(Append-only event log)"]
AppDB[("PostgreSQL\nApplication DB")]
DocStore[("S3 / Object Storage\nDocument Store")]
KF["Kafka\nApplication Events"]
REDIS["Redis\nIdempotency + Session Cache"]
Applicant --> AG
AG --> AS
AS --> KYC
AS --> CB
AS --> UW
AS --> DCS
AS --> OFS
AS --> AppDB
AS --> REDIS
AS --> KF
KF --> NS
KF --> AuditS
KF --> BKS
DCS --> DocStore
OFS --> AppDB
BKS --> DIS
AuditS --> AppDB
Component Responsibilities
| Component | Responsibility |
|---|---|
| Application Service | Central orchestrator — drives state machine, coordinates bureau pulls, KYC, underwriting, documents, and booking |
| KYC/AML Service | Identity document verification; OFAC and sanctions list screening; liveness check |
| Credit Bureau Service | Pulls credit reports from Equifax/Experian/TransUnion; deduplicates pulls per application; caches reports |
| Underwriting Engine | Executes versioned rule sets + ML model; returns approve/decline/refer + reason codes |
| Document Collection Service | Manages document requests, uploads, virus scanning, and completeness validation |
| Offer Service | Generates binding loan offer with APR, term, payment; creates TILA disclosure packet |
| Booking Service | Creates loan record in core banking system; generates loan account number |
| Disbursement Service | Initiates ACH or wire transfer to applicant's bank account |
| Notification Service | Applicant status updates; underwriter task assignments; offer expiry reminders |
| Audit Service | Subscribe-only; writes append-only audit records; immutable |
| Application DB | PostgreSQL — all application metadata, bureau results, decisions, offers (source of truth) |
| Document Store | S3 — all uploaded files; 7-year WORM retention |
| Kafka | Decouples Application Service from notifications, audit, and booking |
| Redis | Bureau pull idempotency keys; application session state; optimistic lock tokens |
Step 4 — Application Lifecycle: End-to-End Flow
The complete journey from applicant submission to loan disbursement.
sequenceDiagram
participant App as Applicant
participant AS as Application Service
participant KYC as KYC/AML Service
participant CB as Credit Bureau Service
participant UW as Underwriting Engine
participant DB as Application DB
participant KF as Kafka
App->>AS: POST /v1/applications {personal_info, income, loan_amount, consent_fcra}
AS->>DB: Validate consent_fcra = true
AS->>DB: INSERT application {status: SUBMITTED}
AS->>KF: Publish application.submitted
AS-->>App: 201 {application_id, status: "submitted"}
Note over AS,KYC: Step 1 — Identity Verification (async)
AS->>KYC: VerifyIdentity(ssn, dob, address, id_document)
KYC-->>AS: {status: VERIFIED, kyc_ref_id}
AS->>DB: UPDATE application status = KYC_PASSED
Note over AS,KYC: Step 2 — AML Screening
AS->>KYC: ScreenAML(name, ssn, country_of_birth)
KYC-->>AS: {status: CLEAR, aml_ref_id}
AS->>DB: UPDATE application status = AML_CLEARED
Note over AS,CB: Step 3 — Credit Pull (idempotent)
AS->>CB: PullCreditReport(ssn, dob, application_id)
CB->>DB: Check: existing_pull WHERE app_id AND bureau AND age < 30 days
DB-->>CB: No existing pull
CB->>Equifax: Pull credit report
Equifax-->>CB: {report, fico_score: 720, tradelines: [...]}
CB->>DB: INSERT bureau_pull {app_id, bureau, score, report_json, pulled_at}
CB-->>AS: {fico_score: 720, report_id}
Note over AS,UW: Step 4 — Automated Underwriting
AS->>UW: Decision(application_data, credit_report, income_verification)
UW-->>AS: {decision: APPROVE, rate: 8.99%, amount: 25000, term: 60, rule_version: v42}
AS->>DB: UPDATE application status = APPROVED, decision_id = uuid
AS->>KF: Publish application.approved {application_id, offer_terms}
Step 5 — Application State Machine
The application lifecycle is a strictly ordered state machine. Invalid transitions — such as disbursing a loan that was not accepted, or re-pulling credit on an already-decided application — must be rejected at the application level.
stateDiagram-v2
[*] --> SUBMITTED : Applicant submits application
SUBMITTED --> KYC_IN_PROGRESS : KYC/AML check initiated
SUBMITTED --> WITHDRAWN : Applicant withdraws before processing
KYC_IN_PROGRESS --> KYC_PASSED : Identity verified + AML clear
KYC_IN_PROGRESS --> KYC_FAILED : Identity mismatch or sanctions hit
KYC_IN_PROGRESS --> KYC_MANUAL_REVIEW : Inconclusive — human review needed
KYC_MANUAL_REVIEW --> KYC_PASSED : Compliance officer clears
KYC_MANUAL_REVIEW --> KYC_FAILED : Compliance officer rejects
KYC_FAILED --> DECLINED : Regulatory decline (no creditworthiness evaluation)
KYC_PASSED --> CREDIT_PULL_IN_PROGRESS : Credit bureau pull initiated
CREDIT_PULL_IN_PROGRESS --> UNDERWRITING : Bureau reports received
CREDIT_PULL_IN_PROGRESS --> CREDIT_PULL_FAILED : All bureaus unavailable
CREDIT_PULL_FAILED --> CREDIT_PULL_IN_PROGRESS : Retry after backoff
UNDERWRITING --> APPROVED : Automated decision — approve
UNDERWRITING --> DECLINED : Automated decision — decline
UNDERWRITING --> REFERRED : Referred to manual underwriter
UNDERWRITING --> CONDITIONAL : Approved with conditions\n(e.g., submit 2 months bank statements)
REFERRED --> APPROVED : Underwriter approves
REFERRED --> DECLINED : Underwriter declines
REFERRED --> CONDITIONAL : Underwriter adds conditions
CONDITIONAL --> APPROVED : Conditions satisfied
CONDITIONAL --> DECLINED : Conditions not satisfied in time
APPROVED --> OFFER_SENT : Loan offer generated and sent to applicant
OFFER_SENT --> OFFER_ACCEPTED : Applicant accepts within validity window
OFFER_SENT --> OFFER_EXPIRED : Offer validity period elapsed (typically 30 days)
OFFER_SENT --> OFFER_REJECTED : Applicant explicitly declines
OFFER_ACCEPTED --> DOCUMENT_COLLECTION : Additional docs required for booking
OFFER_ACCEPTED --> BOOKING_IN_PROGRESS : No docs needed — proceed directly
DOCUMENT_COLLECTION --> BOOKING_IN_PROGRESS : All required docs received + validated
DOCUMENT_COLLECTION --> DECLINED : Docs not received within deadline
BOOKING_IN_PROGRESS --> BOOKED : Loan created in core banking system
BOOKED --> DISBURSEMENT_IN_PROGRESS : Disbursement initiated
DISBURSEMENT_IN_PROGRESS --> DISBURSED : Funds confirmed received by applicant
DISBURSEMENT_IN_PROGRESS --> DISBURSEMENT_FAILED : Payment failed — retry
DISBURSEMENT_FAILED --> DISBURSEMENT_IN_PROGRESS : Retry with corrected details
DISBURSED --> [*]
DECLINED --> [*]
WITHDRAWN --> [*]
OFFER_EXPIRED --> [*]
OFFER_REJECTED --> [*]
Step 6 — Credit Bureau Integration
Credit bureau pulls are the most operationally sensitive external calls in the LOS. Each pull creates a hard inquiry on the applicant's credit report (which can lower their credit score) and costs the lender money (~$0.50–$1.50 per pull). Duplicate pulls on the same application from retries or bugs are both costly and harmful to the applicant.
Idempotent Bureau Pull
sequenceDiagram
participant AS as Application Service
participant CBS as Credit Bureau Service
participant REDIS as Redis
participant DB as Application DB
participant EQ as Equifax API
AS->>CBS: PullReport(application_id, bureau="equifax", ssn, dob)
CBS->>REDIS: GET bureau_pull:{application_id}:equifax
REDIS-->>CBS: Cache MISS
CBS->>DB: SELECT * FROM bureau_pulls\nWHERE application_id = :id\nAND bureau = 'equifax'\nAND pulled_at > NOW() - INTERVAL '30 days'
DB-->>CBS: No recent pull found
CBS->>REDIS: SET NX bureau_pull_lock:{application_id}:equifax TTL=60s
REDIS-->>CBS: OK (lock acquired)
CBS->>EQ: GET /credit-report {ssn, dob, application_id_as_ref}
EQ-->>CBS: {report_json, fico_score: 720, inquiry_id: "EQ-2025-xxxx"}
CBS->>DB: INSERT bureau_pulls {application_id, bureau, fico_score, report_json,\n inquiry_id, pulled_at}
CBS->>REDIS: SET bureau_pull:{application_id}:equifax {score: 720} TTL=30days
CBS->>REDIS: DEL bureau_pull_lock:{application_id}:equifax
CBS-->>AS: {fico_score: 720, report_id, from_cache: false}
Note over AS: Second call (retry scenario)
AS->>CBS: PullReport(application_id, bureau="equifax", ...)
CBS->>REDIS: GET bureau_pull:{application_id}:equifax
REDIS-->>CBS: Cache HIT — {score: 720}
CBS-->>AS: {fico_score: 720, report_id, from_cache: true}
Note over CBS: No new pull initiated — duplicate prevented
Bureau Pull Rules
Pull deduplication window: 30 days per bureau per application
Cached report TTL: 30 days in Redis (aligned with deduplication window)
Retry policy: If bureau API times out → retry with SAME reference_id
Bureau API must return same result for same reference_id (idempotent)
Bureau tri-merge: Pull all 3 bureaus → use middle score (discard highest + lowest)
Soft pull: Pre-qualification checks use soft pull (no credit impact, no cost)
Hard pull: Only on full application with FCRA consent — single pull per bureau
Credit Score to Decision Band
| FICO Range | Risk Band | Typical Decision |
|---|---|---|
| 760 + | Super Prime | Auto-approve; best rate |
| 720 – 759 | Prime Plus | Auto-approve; standard rate |
| 680 – 719 | Prime | Auto-approve; slightly higher rate |
| 640 – 679 | Near Prime | Refer to manual underwriter |
| 600 – 639 | Subprime | Refer; may require collateral |
| < 600 | Deep Subprime | Auto-decline; issue Adverse Action Notice |
Step 7 — Underwriting Decisioning Engine
The Underwriting Engine applies a versioned set of business rules and an ML scoring model to produce an automated decision. The decision must be deterministic (same inputs → same output), auditable (exact rule version stored with every decision), and explainable (reason codes provided for every decline).
Decision Inputs
Application data:
loan_amount, loan_purpose, term_requested
Applicant data:
annual_income, employment_status, employment_years
monthly_rent_or_mortgage, monthly_debt_payments
Credit data:
fico_score (tri-merge middle)
derogatory_marks_count
total_revolving_utilization_pct
oldest_account_age_months
recent_inquiries_count_6m
Calculated ratios:
DTI = (monthly_debt + proposed_payment) / gross_monthly_income
LTV = loan_amount / collateral_value (for secured loans)
PTI = proposed_payment / gross_monthly_income
Decision Rule Engine
Rules are versioned, stored in a rules database, and loaded at engine startup. A rule set is a collection of conditions with outcomes:
Rule Set Version: v42 (effective 2025-01-15)
Hard Decline Rules (any match → auto-decline):
RULE_001: fico_score < 580
RULE_002: active_bankruptcy = true
RULE_003: dti > 55%
RULE_004: derogatory_marks_last_12m >= 3
RULE_005: employment_status = 'UNEMPLOYED' AND loan_purpose != 'DEBT_CONSOLIDATION'
RULE_006: aml_status = 'SANCTIONED'
Refer Rules (any match → manual underwriter):
RULE_101: fico_score BETWEEN 580 AND 640
RULE_102: dti BETWEEN 43% AND 55%
RULE_103: employment_years < 1
RULE_104: recent_inquiries_6m >= 5
RULE_105: income_verification_status = 'UNVERIFIED'
Conditional Approval Rules:
RULE_201: income_unverified → require 2 pay stubs
RULE_202: self_employed AND income > $150K → require 2 years tax returns
RULE_203: loan_amount > $50K → require bank statements
Auto-Approve: none of the above triggered AND ML_score >= 0.70
Decision Output
{
"decision": "APPROVE",
"rule_set_version": "v42",
"decision_id": "dec_9f8e7d6c",
"application_id": "app_abc123",
"decided_at": "2025-03-01T10:00:05Z",
"offer": {
"approved_amount": 25000,
"annual_rate_pct": 8.99,
"term_months": 60,
"monthly_payment": 519.00,
"total_interest": 6140.00
},
"inputs_snapshot": {
"fico_score": 720,
"dti_pct": 34.2,
"annual_income": 85000,
"employment_years": 4.5
},
"rules_evaluated": ["RULE_001", "RULE_002", "RULE_003", "RULE_101"],
"rules_triggered": [],
"ml_score": 0.84,
"reason_codes": []
}
Why snapshot inputs? The applicant's financial situation changes over time. Storing the exact inputs used at decision time allows the decision to be replayed and explained years later — a requirement for ECOA audits.
Adverse Action Notice
When an application is declined, the Fair Credit Reporting Act requires an Adverse Action Notice to be sent within 30 days, identifying the specific reasons for denial.
Adverse Action Notice — required by FCRA / ECOA
Declined reasons (standardized FCRA reason codes):
AA-01: Credit score below minimum threshold
AA-02: Delinquent credit obligations
AA-03: Too many recent credit inquiries
AA-04: Debt-to-income ratio too high
AA-05: Insufficient credit history
AA-06: Employment history insufficient
Notice must include:
- Name and address of credit bureau used
- Applicant's right to free credit report within 60 days
- Applicant's right to dispute inaccurate information
- Contact information for adverse action inquiries
Step 8 — KYC/AML Compliance
Every loan applicant must be verified as a real person (KYC) and screened against government sanctions lists (AML) before any credit evaluation begins. These are legal requirements under the Bank Secrecy Act, USA PATRIOT Act, and FinCEN regulations.
KYC Verification Flow
sequenceDiagram
participant AS as Application Service
participant KYC as KYC/AML Service
participant IDV as Identity Provider\n(Jumio / Persona / Socure)
participant OFAC as OFAC Sanctions API
participant DB as Application DB
AS->>KYC: VerifyIdentity({ssn, dob, address, id_document_images})
KYC->>IDV: SubmitDocuments(passport_front, passport_back, selfie)
IDV-->>KYC: {verified: true, name_match: true, liveness_pass: true, ref_id: "idv_xyz"}
KYC->>KYC: Cross-check: IDV name matches application name
KYC->>KYC: Cross-check: SSN matches DOB and address (via CRA data)
KYC->>OFAC: ScreenName(full_name, dob, ssn_last4, nationality)
OFAC-->>KYC: {match: false, score: 0.02}
KYC->>DB: INSERT kyc_results {application_id, status: VERIFIED, idv_ref, ofac_score}
KYC-->>AS: {status: VERIFIED, kyc_result_id}
AML Risk Scoring
Beyond OFAC screening, an AML risk score is computed for each applicant:
| AML Risk Factor | Weight | Notes |
|---|---|---|
| Country of birth (high-risk jurisdiction) | High | FATF grey/black list countries |
| Transaction pattern anomalies | Medium | Unusual income sources or deposit patterns |
| PEP status (politically exposed person) | High | Enhanced due diligence required |
| Negative news screening | Medium | Media search for fraud, money laundering |
| IP/device geolocation mismatch | Low | IP country ≠ stated address country |
AML Risk Levels:
LOW (score 0–30): Standard processing — no additional review
MEDIUM (score 31–60): Enhanced due diligence — collect source of funds
HIGH (score 61–100): Compliance officer review required before proceeding
HIT (OFAC match): Immediate block — report to FinCEN required
Identity Verification Retry Handling
Document scans sometimes fail due to poor image quality, lighting, or partial obstruction. The system allows up to 3 retry attempts before routing to manual review:
Attempt 1: Auto-verify (60-second timeout)
→ PASS: proceed to AML screening
→ FAIL: notify applicant to re-upload (reason: poor image quality, ID expired, etc.)
Attempt 2: Auto-verify with enhanced processing
→ PASS: proceed
→ FAIL: notify applicant
Attempt 3: Auto-verify
→ PASS: proceed
→ FAIL: route to KYC_MANUAL_REVIEW queue (compliance officer reviews)
Max attempts exceeded → KYC_MANUAL_REVIEW (human review)
Step 9 — Document Collection
After a conditional approval or as a booking prerequisite, the LOS collects required supporting documents. The completeness requirement varies by loan type and income type.
Document Requirements Matrix
| Document Type | Personal Loan | Auto Loan | Mortgage | Self-Employed |
|---|---|---|---|---|
| Government ID | Required | Required | Required | Required |
| Pay stubs (2 most recent) | Required | Required | Required | — |
| W-2 (last 2 years) | Conditional | Conditional | Required | — |
| Tax returns (last 2 years) | — | — | Required | Required |
| Bank statements (3 months) | Conditional | Conditional | Required | Required |
| Employment verification letter | Conditional | — | Required | — |
| Vehicle title / appraisal | — | Required | — | — |
| Property appraisal | — | — | Required | — |
Document Completeness Check
flowchart TB
Upload["Document Uploaded\n(pre-signed S3 URL)"]
Upload --> Scan["Virus Scan\n(ClamAV / cloud scanner)"]
Scan -->|"Infected"| Reject["Reject + notify applicant"]
Scan -->|"Clean"| Extract["Extract metadata\n(OCR if PDF/image)"]
Extract --> Validate["Validate document:\n• Correct type (pay stub vs W2)\n• Not expired (ID docs)\n• Matches application data (name, SSN)"]
Validate -->|"Invalid"| RequestReupload["Request re-upload\nwith specific reason"]
Validate -->|"Valid"| CheckComplete["Check completeness:\nAll required docs received?"]
CheckComplete -->|"No"| Pending["Status: DOCUMENT_COLLECTION\nNotify applicant of remaining docs"]
CheckComplete -->|"Yes"| Ready["All docs verified\n→ Advance to BOOKING_IN_PROGRESS"]
Step 10 — Loan Booking and Disbursement
Once the applicant accepts the offer and all required documents are collected, the loan is booked in the core banking system and funds are disbursed.
Booking Flow
sequenceDiagram
participant AS as Application Service
participant BKS as Booking Service
participant CBS as Core Banking System
participant DIS as Disbursement Service
participant ACH as ACH Payment Rail
participant DB as Application DB
participant KF as Kafka
AS->>BKS: BookLoan(application_id, offer_id, borrower_details)
BKS->>CBS: CreateLoanAccount({borrower_id, amount, rate, term, payment_schedule})
CBS-->>BKS: {loan_account_number: "LN-2025-0042817", created: true}
BKS->>DB: UPDATE application SET status = BOOKED, loan_account = 'LN-2025-0042817'
BKS->>DB: INSERT loan_booking {application_id, loan_account, booked_at}
BKS->>KF: Publish application.loan_booked {application_id, loan_account}
KF->>DIS: Consume event
DIS->>DIS: Validate applicant bank account (micro-deposit or Plaid verification)
DIS->>ACH: InitiateACH({from: "Lender Funding Account", to: applicant_bank, amount})
ACH-->>DIS: {ach_trace_id: "ACH-20250301-xxxx", estimated_arrival: "2025-03-03"}
DIS->>DB: UPDATE application SET status = DISBURSEMENT_IN_PROGRESS, ach_trace_id = :id
DIS->>KF: Publish application.disbursement_initiated {application_id, ach_trace_id}
Note over ACH,DIS: 1–2 business days later
ACH-->>DIS: Webhook: ACH SETTLED — funds confirmed received
DIS->>DB: UPDATE application SET status = DISBURSED, disbursed_at = NOW()
DIS->>KF: Publish application.disbursed {application_id, disbursed_at}
Disbursement Idempotency
Idempotency key: disbursement:{application_id}:{loan_account}
Store: PostgreSQL UNIQUE constraint on disbursements.idempotency_key
Effect: If ACH initiation times out and retries, same key returns existing ACH trace
ACH rail is also idempotent on the lender's origination reference
Booking Failure Handling
If the core banking system is unavailable during booking:
Retry: 3 attempts with exponential backoff (1s → 2s → 4s)
After 3 fails: Application stays in BOOKING_IN_PROGRESS
Background retry job retries every 5 minutes for 4 hours
After 4 hours: Alert engineering + escalate to ops team
Manual resolution: re-trigger booking via admin API
Applicant communication:
Do not notify applicant of technical booking failure
Send "Your loan is being finalized" message
Notify when BOOKED or after ops resolves within 24h SLA
Step 11 — Regulatory Disclosure Generation
The LOS must generate legally compliant disclosures at specific points in the application lifecycle. Generating wrong disclosures or missing required ones creates regulatory liability.
Required Disclosures
| Disclosure | Regulation | When Generated | Content |
|---|---|---|---|
| FCRA Consent | FCRA | Before credit pull | Applicant consent to pull credit report |
| TILA Loan Estimate | Truth in Lending Act | At offer generation | APR, total cost of credit, payment schedule |
| Equal Credit Opportunity Notice | ECOA | At application | Statement of non-discrimination |
| Adverse Action Notice | FCRA / ECOA | Within 30 days of decline | Decline reasons, credit bureau used, rights |
| Final Truth in Lending Disclosure | TILA | Before disbursement | Final terms — must match loan estimate within tolerance |
| Right to Cancel Notice | TILA (Reg Z) | Before disbursement (HELOC/refinance) | 3-day right of rescission |
Disclosure Generation Pipeline
flowchart LR
Trigger["Decision Event\n(APPROVED or DECLINED)"]
Trigger --> DG["Disclosure Generator\n(Template + data merge)"]
DG --> Template["Load disclosure template\nby type + jurisdiction + loan_type"]
Template --> Merge["Merge application data\n(rates, terms, reason codes)"]
Merge --> Validate["Validate APR calculation\n(TILA Appendix J formula)"]
Validate --> PDF["Generate PDF\n(signed with SHA-256 hash)"]
PDF --> S3["Store in S3\n(WORM — 7 year retention)"]
PDF --> DB["Record disclosure metadata\n(type, generated_at, hash, s3_key)"]
PDF --> Deliver["Deliver to applicant\n(portal + email)"]
Deliver --> Track["Track acknowledgment\n(applicant must confirm receipt)"]
Adverse Action Notice — 30-Day SLA
Monitoring:
Cron every hour:
SELECT application_id, decided_at
FROM applications
WHERE status = 'DECLINED'
AND adverse_action_sent_at IS NULL
AND decided_at < NOW() - INTERVAL '28 days'
→ Alert compliance team for applications approaching 30-day deadline
→ P0 alert if adverse_action_sent_at IS NULL AND decided_at < NOW() - INTERVAL '29 days'
→ Force-generate and send if 30-day deadline breached (with incident record)
Step 12 — Database Design
Core Tables
-- Master application record
CREATE TABLE loan_applications (
application_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
applicant_id UUID NOT NULL,
loan_type VARCHAR(20) NOT NULL, -- 'PERSONAL' | 'AUTO' | 'MORTGAGE' | 'HELOC'
requested_amount BIGINT NOT NULL, -- in cents
requested_term INT NOT NULL, -- months
loan_purpose VARCHAR(50) NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'SUBMITTED',
fcra_consent BOOLEAN NOT NULL DEFAULT FALSE,
fcra_consented_at TIMESTAMPTZ,
kyc_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
aml_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
decision_id UUID, -- FK to underwriting_decisions
loan_account VARCHAR(50), -- set on BOOKED
ach_trace_id VARCHAR(100), -- set on DISBURSEMENT_IN_PROGRESS
idempotency_key VARCHAR(255) UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Versioned underwriting decisions — never overwritten
CREATE TABLE underwriting_decisions (
decision_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
application_id UUID NOT NULL REFERENCES loan_applications(application_id),
decision VARCHAR(20) NOT NULL, -- 'APPROVE' | 'DECLINE' | 'REFER' | 'CONDITIONAL'
rule_set_version VARCHAR(10) NOT NULL,
decided_by VARCHAR(20) NOT NULL, -- 'SYSTEM' | underwriter user_id
approved_amount BIGINT,
annual_rate_bps INT, -- basis points (899 = 8.99%)
term_months INT,
monthly_payment BIGINT,
inputs_snapshot JSONB NOT NULL, -- exact inputs used — for audit replay
rules_evaluated TEXT[],
rules_triggered TEXT[],
reason_codes TEXT[],
ml_score DECIMAL(5,4),
decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Credit bureau pulls — one row per bureau per application
CREATE TABLE bureau_pulls (
pull_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
application_id UUID NOT NULL REFERENCES loan_applications(application_id),
bureau VARCHAR(20) NOT NULL, -- 'EQUIFAX' | 'EXPERIAN' | 'TRANSUNION'
fico_score INT,
report_json JSONB, -- encrypted at application level
inquiry_id VARCHAR(100), -- bureau-side reference
pull_type VARCHAR(10) NOT NULL DEFAULT 'HARD', -- 'SOFT' | 'HARD'
pulled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (application_id, bureau, pull_type, DATE(pulled_at))
);
-- KYC and AML results
CREATE TABLE kyc_results (
kyc_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
application_id UUID NOT NULL REFERENCES loan_applications(application_id),
check_type VARCHAR(10) NOT NULL, -- 'KYC' | 'AML' | 'OFAC'
status VARCHAR(20) NOT NULL,
provider VARCHAR(50) NOT NULL, -- 'SOCURE' | 'JUMIO' | 'OFAC_API'
provider_ref_id VARCHAR(100),
risk_score DECIMAL(5,2),
flags TEXT[],
raw_response JSONB, -- encrypted; retained for audit
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Immutable application event log
CREATE TABLE application_events (
event_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
application_id UUID NOT NULL REFERENCES loan_applications(application_id),
event_type VARCHAR(50) NOT NULL,
from_status VARCHAR(40),
to_status VARCHAR(40),
actor_type VARCHAR(20) NOT NULL, -- 'APPLICANT' | 'SYSTEM' | 'UNDERWRITER' | 'COMPLIANCE'
actor_id VARCHAR(100) NOT NULL,
notes TEXT,
metadata JSONB,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Document tracking
CREATE TABLE application_documents (
document_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
application_id UUID NOT NULL REFERENCES loan_applications(application_id),
document_type VARCHAR(50) NOT NULL,
filename VARCHAR(255) NOT NULL,
s3_key VARCHAR(500) NOT NULL,
file_hash VARCHAR(64),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING_SCAN',
validated_by VARCHAR(20), -- 'SYSTEM' | reviewer user_id
rejection_reason TEXT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Regulatory disclosures
CREATE TABLE regulatory_disclosures (
disclosure_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
application_id UUID NOT NULL REFERENCES loan_applications(application_id),
disclosure_type VARCHAR(50) NOT NULL, -- 'TILA_ESTIMATE' | 'ADVERSE_ACTION' | 'FCRA_CONSENT' | ...
s3_key VARCHAR(500) NOT NULL,
file_hash VARCHAR(64) NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
acknowledged_at TIMESTAMPTZ
);
Key Indexes
-- Application lookup by applicant
CREATE INDEX idx_apps_applicant ON loan_applications(applicant_id, created_at DESC);
-- Applications by status (queue management)
CREATE INDEX idx_apps_status ON loan_applications(status, created_at);
-- Underwriting queue (manual review)
CREATE INDEX idx_apps_referred ON loan_applications(status, updated_at)
WHERE status IN ('REFERRED', 'KYC_MANUAL_REVIEW');
-- Bureau pull dedup check
CREATE INDEX idx_bureau_app_bureau ON bureau_pulls(application_id, bureau, pull_type);
-- Adverse action SLA monitoring
CREATE INDEX idx_apps_declined_no_aa ON loan_applications(status, updated_at)
WHERE status = 'DECLINED';
-- Events per application (timeline view)
CREATE INDEX idx_events_application ON application_events(application_id, occurred_at DESC);
Step 13 — API Design
Submit Application
POST /v1/applications
{
"idempotency_key": "app-user-U789-2025-03-01T10:00:00Z",
"loan_type": "personal",
"requested_amount": 25000,
"requested_term": 60,
"loan_purpose": "debt_consolidation",
"fcra_consent": true,
"fcra_consented_at": "2025-03-01T10:00:00Z",
"applicant": {
"first_name": "Alex",
"last_name": "Johnson",
"ssn": "XXX-XX-XXXX",
"date_of_birth": "1990-05-15",
"email": "[email protected]",
"phone": "+14155550100",
"address": {
"line1": "123 Main St", "city": "San Francisco",
"state": "CA", "zip": "94105"
}
},
"financial": {
"annual_income": 85000,
"employment_status": "employed",
"employer_name": "Acme Corp",
"employment_years": 4.5,
"monthly_rent": 2200,
"monthly_debt": 850
}
}
201 Created response:
{
"application_id": "app_9f8e7d6c5b4a",
"status": "submitted",
"next_step": "Identity verification in progress. You will receive a decision within 24 hours.",
"created_at": "2025-03-01T10:00:02Z"
}
Get Application Status
GET /v1/applications/{application_id}
{
"application_id": "app_9f8e7d6c5b4a",
"status": "offer_sent",
"loan_type": "personal",
"offer": {
"approved_amount": 25000,
"annual_rate_pct": 8.99,
"term_months": 60,
"monthly_payment": 519.00,
"offer_expires_at": "2025-03-31T23:59:59Z"
},
"timeline": [
{ "event": "Application submitted", "date": "2025-03-01T10:00:02Z" },
{ "event": "Identity verified", "date": "2025-03-01T10:00:45Z" },
{ "event": "Credit report pulled", "date": "2025-03-01T10:01:12Z" },
{ "event": "Application approved", "date": "2025-03-01T10:01:18Z" },
{ "event": "Offer sent", "date": "2025-03-01T10:01:20Z" }
]
}
Accept Offer
POST /v1/applications/{application_id}/accept
{
"offer_id": "offer_xyz789",
"disbursement_method": "ach",
"bank_account": {
"routing_number": "021000021",
"account_number": "XXXXXXXX1234",
"account_type": "checking"
}
}
200 OK:
{
"application_id": "app_9f8e7d6c5b4a",
"status": "booking_in_progress",
"loan_account": "LN-2025-0042817",
"estimated_funding": "2025-03-03"
}
Step 14 — Kafka Event Architecture
flowchart TB
AS["Application Service"]
AS --> E1["application.submitted"]
AS --> E2["application.kyc_completed"]
AS --> E3["application.credit_pulled"]
AS --> E4["application.decision_made"]
AS --> E5["application.offer_sent"]
AS --> E6["application.offer_accepted"]
AS --> E7["application.loan_booked"]
AS --> E8["application.disbursed"]
AS --> E9["application.declined"]
E1 --> AuditS["Audit Service\n(all events)"]
E1 --> NS["Notification\n(submission confirmation)"]
E4 --> DG["Disclosure Generator\n(TILA / Adverse Action)"]
E4 --> NS2["Notification\n(decision notification)"]
E4 --> AuditS
E6 --> BKS["Booking Service\n(create loan account)"]
E6 --> NS3["Notification\n(acceptance confirmation)"]
E7 --> DIS["Disbursement Service\n(initiate ACH)"]
E9 --> ComplianceChecker["Adverse Action\nSLA Monitor"]
E9 --> NS4["Notification\n(decline + rights notice)"]
Step 15 — Failure Handling and Observability
Credit Bureau Unavailability
flowchart TB
PullRequest["Credit Pull Request"]
PullRequest --> Attempt["Attempt Equifax API\n(5-second timeout)"]
Attempt -->|"Success"| Store["Store report + score"]
Attempt -->|"Timeout/5xx"| Retry["Retry Experian\n(fallback bureau)"]
Retry -->|"Success"| Store
Retry -->|"Timeout/5xx"| Retry2["Retry TransUnion\n(second fallback)"]
Retry2 -->|"Success"| Store
Retry2 -->|"All bureaus down"| Queue["Queue application\nfor retry in 15 minutes\n(status: CREDIT_PULL_FAILED)"]
Queue --> Alert["Alert engineering\nif > 10 apps stuck > 1 hour"]
Key Metrics
| Metric | Description | Alert Threshold |
|---|---|---|
applications.submission_success_rate |
% applications created without error | < 99.5% |
applications.automated_decision_rate |
% applications auto-decided (not referred) | < 60% (rules miscalibrated) |
applications.avg_time_to_decision_sec |
Avg time from submission to decision | > 120 seconds |
bureau.pull_duplicate_rate |
% bureau pulls that were prevented dedup | Spike alert |
bureau.api_error_rate |
% bureau API calls failing | > 5% |
kyc.failure_rate |
% KYC verifications failing | > 10% (identity provider issue) |
applications.adverse_action_overdue |
Declined apps without AA notice > 28 days | Any — P0 alert |
applications.offer_expiry_rate |
% offers expiring without acceptance | > 40% (pricing issue signal) |
disbursements.failure_rate |
ACH disbursement failures | > 1% |
kafka.consumer_lag |
Lag on application event topics | > 5,000 messages |
Operations Dashboard
| Pipeline Stage | Count (today) | Avg Time in Stage | SLA Breaches |
|---|---|---|---|
| Submitted | 48 | 2 min | 0 |
| KYC In Progress | 12 | 8 min | 0 |
| Credit Pull | 6 | 25 sec | 0 |
| Underwriting | 3 | 15 sec | 0 |
| Referred (manual) | 28 | 4.2 hours | 2 ⚠️ |
| Document Collection | 74 | 3.1 days | 5 ⚠️ |
| Booking / Disbursement | 31 | 1.8 days | 0 |
| Total Active | 202 | — | 7 ⚠️ |
Design Trade-offs
Trade-off 1: Synchronous vs Asynchronous Underwriting
| Approach | Pros | Cons |
|---|---|---|
| Synchronous decisioning (chosen for bureau + rules) | Applicant gets instant decision in the same session; bureau pull + rules engine completes in < 30s | API call is long-running; requires proper timeout handling |
| Fully asynchronous | Fast API response; bureau pull in background | Applicant must return to check status; worse UX for digital-first applications |
Decision: Synchronous within a 30-second window. The bureau pull (< 5s) + rules engine (< 1s) + ML model (< 2s) complete in under 30 seconds. Returning a decision in the same session is a significant UX advantage. An async callback is provided as fallback if the 30-second timeout is hit.
Trade-off 2: Rule Engine vs ML-Only Decisioning
| Approach | Pros | Cons |
|---|---|---|
| Rule engine + ML hybrid (chosen) | Rules are auditable and explainable; hard decline rules are deterministic; FCRA reason codes generated from rules | Two systems to maintain and align |
| ML-only | Higher accuracy; adapts to new fraud patterns | Black-box — cannot generate FCRA reason codes; regulators require explainability |
Decision: Hybrid. FCRA requires specific, human-readable reason codes for every decline. ML models cannot reliably produce these. Rules engine handles hard declines (always explainable), ML model scores the middle band (prime/near-prime), and the hybrid produces both a score and reason codes.
Trade-off 3: Central Application Service vs Parallel Pipeline
| Approach | Pros | Cons |
|---|---|---|
| Sequential orchestration (chosen) — KYC → bureau → underwriting in sequence | Simple; each step depends on the previous; clear state | KYC + bureau steps are serialized; adds latency |
| Parallel pipeline — run KYC and bureau simultaneously | Saves 5–8 seconds per application | KYC failure may have already triggered a bureau pull (wasted hard inquiry + cost) |
Decision: Sequential. KYC must pass before a credit bureau pull. A failed KYC means a hard inquiry was never justified — creating an inquiry on a fraudulent application's target victim's credit file would be a compliance violation. The latency cost (~8 seconds) is acceptable for this workflow.
Trade-off 4: Storing Raw Bureau Reports vs Derived Data Only
| Approach | Pros | Cons |
|---|---|---|
| Store full raw report JSON (chosen) | Decision can be replayed exactly; ECOA audit requires exact inputs; troubleshooting is possible | Large storage per application (~45 KB of JSON per app); PII in report must be encrypted |
| Store derived features only | Much smaller storage | Cannot replay decision; cannot answer "why was this declined" for a regulator years later |
Decision: Store the full raw report, encrypted at the column level. The regulatory cost of not being able to replay a decision vastly outweighs the storage cost. 20,000 applications × 45 KB = 900 MB/month — negligible.
Common Interview Mistakes
| Mistake | Why It Matters |
|---|---|
| Not enforcing FCRA consent before bureau pull | Pulling credit without consent is a FCRA violation with civil liability |
| Missing idempotency on bureau pulls | Each duplicate pull costs money and harms the applicant's credit score — a regulatory violation under FCRA |
| Using a mutable decision record | ECOA requires the ability to reproduce the exact decision — if you can update the decision record, the audit trail is worthless |
| Not generating Adverse Action Notices within 30 days | ECOA and FCRA violations; $10,000+ per violation in statutory damages |
| Designing underwriting as black-box ML only | FCRA requires specific reason codes — ML alone cannot provide FCRA-compliant decline reasons |
| Not sequencing KYC before credit pull | Running a hard credit inquiry on an identity that fails KYC creates a fraudulent hard inquiry on a real person's credit file |
| Conflating loan booking with disbursement | These are separate operations — the loan is legally created at booking; disbursement is the physical fund transfer; they can fail independently |
| Storing PII (SSN, bureau reports) unencrypted | A data breach of unencrypted SSNs and credit reports creates catastrophic regulatory and legal liability |
Summary
The Loan Origination System sits at the intersection of financial regulation, identity compliance, credit risk, and core banking — making it one of the most complex enterprise systems to design correctly.
flowchart TB
Core["Loan Origination System"]
Core --> KYC_Block["KYC / AML\nIdentity verification\nSanctions screening"]
Core --> Bureau["Credit Bureau\nIdempotent pull\nTri-merge scoring"]
Core --> UW["Underwriting Engine\nVersioned rules + ML\nAuditable decisions"]
Core --> Docs["Document Collection\nCompleteness check\nWORM storage"]
Core --> Compliance["Regulatory Compliance\nTILA disclosures\nAdverse Action Notice"]
Core --> Booking["Booking + Disbursement\nCore banking integration\nACH / wire"]
The four non-negotiable design principles:
-
Credit pulls are idempotent and consent-gated — a hard inquiry without FCRA consent, or a duplicate hard inquiry from a retry bug, is a regulatory violation with individual financial harm to the applicant.
-
Decisions are versioned, immutable, and reproducible — every decline must be explainable with reason codes and the exact inputs used, years after the fact. The rule set version and a snapshot of all inputs are stored with every decision record.
-
KYC is a prerequisite, not a parallel step — the credit evaluation must not begin until identity is verified. Running a credit pull on an unverified identity violates consumer protection law.
-
Adverse Action is a hard deadline, not a best effort — the 30-day ECOA/FCRA deadline for sending an Adverse Action Notice is monitored continuously, alerted at 48 hours, and treated as a P0 compliance incident if breached.