Full Stack • Java • System Design • Cloud • AI Engineering

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.