Building a Modern Loan Origination System
A deep dive into designing and implementing a robust loan origination system in TypeScript, covering application intake, document management, workflow orchestration, and decision engines.
Loan origination is the foundational process in any lending platform. It encompasses everything from the moment a borrower expresses interest in a loan to the point where funds are disbursed. Building a modern loan origination system (LOS) requires careful architectural decisions, robust data modeling, and tight integration with external services such as credit bureaus, identity verification providers, and document management systems.
In this article, we walk through the key components of a production-grade loan origination system built in TypeScript, covering application intake, workflow orchestration, decision engines, and the data persistence layer that ties everything together.
Defining the Application Data Model
The application data model sits at the heart of every origination system. It must be flexible enough to accommodate multiple loan products---personal loans, auto loans, mortgages---while remaining strict enough to enforce business rules at the type level.
interface LoanApplication {
id: string;
borrowerId: string;
productType: LoanProductType;
requestedAmount: number;
requestedTermMonths: number;
purpose: LoanPurpose;
status: ApplicationStatus;
createdAt: Date;
updatedAt: Date;
metadata: Record<string, unknown>;
}
type LoanProductType = "personal" | "auto" | "mortgage" | "sme";
type ApplicationStatus =
| "draft"
| "submitted"
| "in_review"
| "approved"
| "conditionally_approved"
| "declined"
| "withdrawn"
| "funded";
type LoanPurpose =
| "debt_consolidation"
| "home_improvement"
| "vehicle_purchase"
| "business_expansion"
| "education"
| "other";Using discriminated unions for status and product type gives us compile-time safety when writing branching logic. If a new status is added, TypeScript's exhaustiveness checking ensures every switch statement is updated accordingly.
A practical tip: store the full history of status transitions in a separate ApplicationEvent table. This audit trail is invaluable for compliance reporting and debugging production issues.
interface ApplicationEvent {
id: string;
applicationId: string;
fromStatus: ApplicationStatus | null;
toStatus: ApplicationStatus;
triggeredBy: string;
reason?: string;
timestamp: Date;
}
class ApplicationEventStore {
constructor(private readonly repository: EventRepository) {}
async recordTransition(
applicationId: string,
from: ApplicationStatus | null,
to: ApplicationStatus,
actor: string,
reason?: string
): Promise<ApplicationEvent> {
const event: ApplicationEvent = {
id: crypto.randomUUID(),
applicationId,
fromStatus: from,
toStatus: to,
triggeredBy: actor,
reason,
timestamp: new Date(),
};
await this.repository.save(event);
return event;
}
async getHistory(applicationId: string): Promise<ApplicationEvent[]> {
return this.repository.findByApplicationId(applicationId);
}
}Workflow Orchestration with a State Machine
Loan origination is inherently a multi-step process with well-defined transitions. A state machine is the natural abstraction for governing how an application moves through the pipeline. Rather than scattering transition logic across controllers and services, centralize it in a dedicated orchestrator.
interface StateTransition {
from: ApplicationStatus;
to: ApplicationStatus;
guard?: (application: LoanApplication) => Promise<boolean>;
onEnter?: (application: LoanApplication) => Promise<void>;
}
class OriginationStateMachine {
private transitions: StateTransition[] = [
{
from: "draft",
to: "submitted",
guard: async (app) => this.validateCompleteness(app),
},
{
from: "submitted",
to: "in_review",
onEnter: async (app) => this.triggerUnderwriting(app),
},
{
from: "in_review",
to: "approved",
guard: async (app) => this.checkUnderwritingResult(app),
},
{
from: "in_review",
to: "conditionally_approved",
guard: async (app) => this.hasOutstandingConditions(app),
},
{
from: "in_review",
to: "declined",
},
{
from: "approved",
to: "funded",
guard: async (app) => this.verifyDisbursementReady(app),
onEnter: async (app) => this.initiateDisbursement(app),
},
{
from: "conditionally_approved",
to: "approved",
guard: async (app) => this.allConditionsCleared(app),
},
];
async transition(
application: LoanApplication,
targetStatus: ApplicationStatus
): Promise<LoanApplication> {
const validTransition = this.transitions.find(
(t) => t.from === application.status && t.to === targetStatus
);
if (!validTransition) {
throw new InvalidTransitionError(application.status, targetStatus);
}
if (validTransition.guard) {
const allowed = await validTransition.guard(application);
if (!allowed) {
throw new TransitionGuardFailedError(application.status, targetStatus);
}
}
application.status = targetStatus;
application.updatedAt = new Date();
if (validTransition.onEnter) {
await validTransition.onEnter(application);
}
return application;
}
private async validateCompleteness(app: LoanApplication): Promise<boolean> {
return (
app.requestedAmount > 0 &&
app.requestedTermMonths > 0 &&
!!app.borrowerId
);
}
private async triggerUnderwriting(app: LoanApplication): Promise<void> {
// Publish event to underwriting queue
}
private async checkUnderwritingResult(app: LoanApplication): Promise<boolean> {
return true; // Simplified for illustration
}
private async hasOutstandingConditions(app: LoanApplication): Promise<boolean> {
return false;
}
private async verifyDisbursementReady(app: LoanApplication): Promise<boolean> {
return true;
}
private async initiateDisbursement(app: LoanApplication): Promise<void> {
// Trigger funds transfer
}
private async allConditionsCleared(app: LoanApplication): Promise<boolean> {
return true;
}
}Guard functions are asynchronous because they often need to query external systems---checking a document repository for uploaded files, verifying KYC status, or confirming that a credit pull has been completed. The onEnter callbacks allow side effects to be triggered deterministically whenever an application enters a particular state.
Document Collection and Management
A significant portion of the origination process revolves around document collection. Borrowers must supply income verification, identification, bank statements, and product-specific documents such as vehicle titles or property appraisals.
interface DocumentRequirement {
documentType: DocumentType;
required: boolean;
productTypes: LoanProductType[];
description: string;
}
type DocumentType =
| "government_id"
| "proof_of_income"
| "bank_statement"
| "tax_return"
| "property_appraisal"
| "vehicle_title"
| "business_registration";
interface UploadedDocument {
id: string;
applicationId: string;
documentType: DocumentType;
fileName: string;
mimeType: string;
storageKey: string;
uploadedAt: Date;
verificationStatus: "pending" | "verified" | "rejected";
rejectionReason?: string;
}
class DocumentService {
constructor(
private readonly storage: ObjectStorage,
private readonly requirements: DocumentRequirement[]
) {}
getRequiredDocuments(productType: LoanProductType): DocumentRequirement[] {
return this.requirements.filter(
(req) => req.productTypes.includes(productType) && req.required
);
}
async upload(
applicationId: string,
documentType: DocumentType,
file: Buffer,
fileName: string,
mimeType: string
): Promise<UploadedDocument> {
const storageKey = `applications/${applicationId}/${documentType}/${crypto.randomUUID()}`;
await this.storage.put(storageKey, file, { contentType: mimeType });
return {
id: crypto.randomUUID(),
applicationId,
documentType,
fileName,
mimeType,
storageKey,
uploadedAt: new Date(),
verificationStatus: "pending",
};
}
async checkCompleteness(
applicationId: string,
productType: LoanProductType,
uploadedDocs: UploadedDocument[]
): Promise<{ complete: boolean; missing: DocumentType[] }> {
const required = this.getRequiredDocuments(productType);
const uploadedTypes = new Set(uploadedDocs.map((d) => d.documentType));
const missing = required
.filter((req) => !uploadedTypes.has(req.documentType))
.map((req) => req.documentType);
return { complete: missing.length === 0, missing };
}
}A practical tip: implement a document completeness check as a guard on the "draft to submitted" transition. This prevents incomplete applications from entering the review queue and wasting underwriter time.
Integration with External Data Providers
No origination system operates in isolation. Credit bureau pulls, identity verification, income verification, and anti-money-laundering checks are all external integrations that must be orchestrated reliably.
interface CreditBureauResponse {
score: number;
reportId: string;
tradelines: Tradeline[];
inquiries: Inquiry[];
publicRecords: PublicRecord[];
retrievedAt: Date;
}
interface ExternalDataOrchestrator {
pullCredit(borrowerId: string): Promise<CreditBureauResponse>;
verifyIdentity(borrowerId: string): Promise<IdentityVerificationResult>;
verifyIncome(borrowerId: string): Promise<IncomeVerificationResult>;
screenAML(borrowerId: string): Promise<AMLScreeningResult>;
}
class DataPullService {
constructor(
private readonly orchestrator: ExternalDataOrchestrator,
private readonly cache: DataPullCache
) {}
async executeRequiredPulls(
application: LoanApplication
): Promise<DataPullResults> {
const borrowerId = application.borrowerId;
const cachedCredit = await this.cache.getRecentCreditPull(
borrowerId,
30 // days
);
const [credit, identity, income, aml] = await Promise.allSettled([
cachedCredit
? Promise.resolve(cachedCredit)
: this.orchestrator.pullCredit(borrowerId),
this.orchestrator.verifyIdentity(borrowerId),
this.orchestrator.verifyIncome(borrowerId),
this.orchestrator.screenAML(borrowerId),
]);
return this.aggregateResults({ credit, identity, income, aml });
}
private aggregateResults(raw: Record<string, PromiseSettledResult<unknown>>): DataPullResults {
const results: DataPullResults = {
successful: [],
failed: [],
};
for (const [key, result] of Object.entries(raw)) {
if (result.status === "fulfilled") {
results.successful.push({ type: key, data: result.value });
} else {
results.failed.push({ type: key, error: result.reason });
}
}
return results;
}
}Using Promise.allSettled rather than Promise.all is critical here. A failure in one external provider should not prevent the others from completing. The underwriting engine can still make decisions with partial data---for example, declining due to a failed AML screening even if the credit pull timed out.
Putting It All Together: The Origination Service
The top-level origination service composes all of the pieces discussed above into a cohesive workflow.
class LoanOriginationService {
constructor(
private readonly stateMachine: OriginationStateMachine,
private readonly documentService: DocumentService,
private readonly dataPullService: DataPullService,
private readonly eventStore: ApplicationEventStore,
private readonly applicationRepo: ApplicationRepository
) {}
async submitApplication(applicationId: string, actor: string): Promise<LoanApplication> {
const application = await this.applicationRepo.findById(applicationId);
if (!application) throw new ApplicationNotFoundError(applicationId);
const docs = await this.documentService.checkCompleteness(
applicationId,
application.productType,
await this.applicationRepo.getDocuments(applicationId)
);
if (!docs.complete) {
throw new IncompleteDocumentsError(docs.missing);
}
const updated = await this.stateMachine.transition(application, "submitted");
await this.applicationRepo.save(updated);
await this.eventStore.recordTransition(
applicationId,
"draft",
"submitted",
actor,
"Application submitted with all required documents"
);
// Kick off data pulls asynchronously
this.dataPullService.executeRequiredPulls(updated).catch((err) => {
console.error(`Data pull failed for application ${applicationId}`, err);
});
return updated;
}
}Conclusion
A well-architected loan origination system is built on a few core principles: strongly typed data models that make invalid states unrepresentable, a centralized state machine that governs workflow transitions, a document management layer that enforces completeness requirements, and resilient integrations with external data providers.
TypeScript's type system is a natural fit for modeling the complex domain rules inherent in lending. Discriminated unions, exhaustiveness checking, and interface-driven design allow teams to encode business rules directly into the type system, catching entire categories of bugs at compile time rather than in production.
The patterns demonstrated here---event sourcing for audit trails, guard-based state transitions, and fail-safe external data orchestration---form a foundation that can be extended with more sophisticated underwriting logic, multi-product support, and regulatory reporting as the platform matures.
Related Articles
Designing APIs for Lending Platforms
A comprehensive guide to designing robust, secure, and developer-friendly APIs for lending platforms, covering RESTful resource modeling, webhook architectures, idempotency, versioning, and partner integration patterns in TypeScript.
Risk Management in Lending: Architecture and Strategy
A strategic guide to building a comprehensive risk management framework for lending platforms, covering credit risk, portfolio management, stress testing, concentration limits, and loss forecasting.
Digital Lending Trends: What's Next for Fintech
A business-focused analysis of the trends shaping digital lending, including embedded finance, alternative data, real-time decisioning, open banking, and the evolution of lending-as-a-service platforms.