Automating Underwriting with Rule Engines

A comprehensive guide to building a rule-based underwriting automation system in TypeScript, covering rule engines, policy configuration, exception handling, and decisioning workflows.

technical9 min readBy Klivvr Engineering
Share:

Underwriting is the process of evaluating a borrower's creditworthiness and determining the terms under which a loan can be offered. Traditionally performed by human underwriters reviewing applications one by one, modern lending platforms automate the vast majority of these decisions using rule engines. Automation reduces decision times from days to seconds, eliminates human inconsistency, and allows lending teams to scale without proportionally growing headcount.

This article covers the design and implementation of an underwriting automation system in TypeScript, focusing on rule engine architecture, policy composition, exception handling, and the interplay between automated and manual decision paths.

Rule Engine Architecture

A rule engine evaluates a set of conditions against input data and produces an outcome. In the context of underwriting, the input is the borrower's application and supporting data, the conditions encode credit policy, and the outcome is an approval, decline, or referral for manual review.

interface UnderwritingRule {
  id: string;
  name: string;
  description: string;
  priority: number;
  condition: RuleCondition;
  outcome: RuleOutcome;
  isActive: boolean;
}
 
type RuleCondition =
  | { type: "threshold"; field: string; operator: ComparisonOperator; value: number }
  | { type: "in_set"; field: string; values: (string | number)[] }
  | { type: "and"; conditions: RuleCondition[] }
  | { type: "or"; conditions: RuleCondition[] }
  | { type: "not"; condition: RuleCondition }
  | { type: "custom"; evaluator: string };
 
type ComparisonOperator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq";
 
type RuleOutcome =
  | { type: "decline"; reasonCode: string }
  | { type: "refer"; reasonCode: string; referralQueue: string }
  | { type: "condition"; conditionCode: string; description: string }
  | { type: "pass" };

The recursive RuleCondition type allows arbitrarily complex boolean logic through composition. A rule that checks whether a borrower has a credit score above 680 AND a debt-to-income ratio below 0.43 can be expressed as:

const qualifiedBorrowerRule: UnderwritingRule = {
  id: "rule_001",
  name: "Minimum credit and DTI check",
  description: "Borrower must have score >= 680 and DTI < 0.43",
  priority: 10,
  isActive: true,
  condition: {
    type: "and",
    conditions: [
      { type: "threshold", field: "credit_score", operator: "gte", value: 680 },
      { type: "threshold", field: "debt_to_income_ratio", operator: "lt", value: 0.43 },
    ],
  },
  outcome: { type: "pass" },
};

Building the Rule Evaluator

The evaluator recursively traverses the condition tree and resolves each node against the input data.

class RuleEvaluator {
  evaluate(condition: RuleCondition, data: Record<string, unknown>): boolean {
    switch (condition.type) {
      case "threshold":
        return this.evaluateThreshold(condition, data);
      case "in_set":
        return this.evaluateInSet(condition, data);
      case "and":
        return condition.conditions.every((c) => this.evaluate(c, data));
      case "or":
        return condition.conditions.some((c) => this.evaluate(c, data));
      case "not":
        return !this.evaluate(condition.condition, data);
      case "custom":
        return this.evaluateCustom(condition.evaluator, data);
    }
  }
 
  private evaluateThreshold(
    condition: Extract<RuleCondition, { type: "threshold" }>,
    data: Record<string, unknown>
  ): boolean {
    const value = this.resolveField(condition.field, data);
    if (typeof value !== "number") return false;
 
    switch (condition.operator) {
      case "gt": return value > condition.value;
      case "gte": return value >= condition.value;
      case "lt": return value < condition.value;
      case "lte": return value <= condition.value;
      case "eq": return value === condition.value;
      case "neq": return value !== condition.value;
    }
  }
 
  private evaluateInSet(
    condition: Extract<RuleCondition, { type: "in_set" }>,
    data: Record<string, unknown>
  ): boolean {
    const value = this.resolveField(condition.field, data);
    return condition.values.includes(value as string | number);
  }
 
  private resolveField(field: string, data: Record<string, unknown>): unknown {
    const parts = field.split(".");
    let current: unknown = data;
 
    for (const part of parts) {
      if (current === null || current === undefined) return undefined;
      current = (current as Record<string, unknown>)[part];
    }
 
    return current;
  }
 
  private evaluateCustom(evaluatorName: string, data: Record<string, unknown>): boolean {
    // Custom evaluators are registered functions for complex logic
    // that cannot be expressed as simple threshold or set conditions
    const evaluator = customEvaluatorRegistry.get(evaluatorName);
    if (!evaluator) throw new Error(`Unknown custom evaluator: ${evaluatorName}`);
    return evaluator(data);
  }
}
 
const customEvaluatorRegistry = new Map<string, (data: Record<string, unknown>) => boolean>();
 
// Register custom evaluators for complex business logic
customEvaluatorRegistry.set("has_sufficient_reserves", (data) => {
  const reserves = data["bank_balance"] as number;
  const monthlyPayment = data["estimated_monthly_payment"] as number;
  return reserves >= monthlyPayment * 3;
});

Dot-notation field resolution ("creditReport.score") enables the rule definitions to reach into nested data structures without flattening the input. This keeps the data model clean while giving rule authors flexibility.

Policy Composition and Rule Sets

Individual rules are organized into rule sets that represent a credit policy. A rule set defines the evaluation order and the logic for combining individual rule outcomes into a final decision.

interface RuleSet {
  id: string;
  name: string;
  productType: string;
  version: number;
  evaluationStrategy: "first_match" | "all_rules" | "worst_outcome";
  rules: UnderwritingRule[];
  effectiveDate: Date;
  expirationDate?: Date;
}
 
interface UnderwritingDecision {
  applicationId: string;
  ruleSetId: string;
  ruleSetVersion: number;
  outcome: "approved" | "declined" | "referred" | "conditionally_approved";
  triggeredRules: TriggeredRule[];
  conditions: string[];
  decisionTimestamp: Date;
  executionTimeMs: number;
}
 
interface TriggeredRule {
  ruleId: string;
  ruleName: string;
  outcome: RuleOutcome;
  matched: boolean;
}
 
class UnderwritingEngine {
  constructor(private readonly evaluator: RuleEvaluator) {}
 
  execute(
    applicationId: string,
    ruleSet: RuleSet,
    data: Record<string, unknown>
  ): UnderwritingDecision {
    const startTime = Date.now();
    const sortedRules = [...ruleSet.rules]
      .filter((r) => r.isActive)
      .sort((a, b) => a.priority - b.priority);
 
    const triggeredRules: TriggeredRule[] = [];
    const conditions: string[] = [];
    let finalOutcome: "approved" | "declined" | "referred" | "conditionally_approved" = "approved";
 
    for (const rule of sortedRules) {
      const matched = this.evaluator.evaluate(rule.condition, data);
 
      triggeredRules.push({
        ruleId: rule.id,
        ruleName: rule.name,
        outcome: rule.outcome,
        matched,
      });
 
      if (!matched && rule.outcome.type !== "pass") {
        // The condition was NOT met, meaning this rule fires its outcome
        if (ruleSet.evaluationStrategy === "first_match") {
          finalOutcome = this.mapOutcomeType(rule.outcome);
          break;
        }
 
        if (rule.outcome.type === "decline") {
          finalOutcome = "declined";
          if (ruleSet.evaluationStrategy === "worst_outcome") continue;
          break;
        }
 
        if (rule.outcome.type === "refer") {
          if (finalOutcome !== "declined") finalOutcome = "referred";
        }
 
        if (rule.outcome.type === "condition") {
          conditions.push(rule.outcome.description);
          if (finalOutcome === "approved") finalOutcome = "conditionally_approved";
        }
      }
    }
 
    return {
      applicationId,
      ruleSetId: ruleSet.id,
      ruleSetVersion: ruleSet.version,
      outcome: finalOutcome,
      triggeredRules,
      conditions,
      decisionTimestamp: new Date(),
      executionTimeMs: Date.now() - startTime,
    };
  }
 
  private mapOutcomeType(outcome: RuleOutcome): UnderwritingDecision["outcome"] {
    switch (outcome.type) {
      case "decline": return "declined";
      case "refer": return "referred";
      case "condition": return "conditionally_approved";
      case "pass": return "approved";
    }
  }
}

The three evaluation strategies serve different use cases. "First match" is fastest and appropriate when rules are ordered from most disqualifying to least. "All rules" evaluates every rule and accumulates conditions, which is useful for producing a comprehensive list of stipulations. "Worst outcome" evaluates all rules and returns the most severe result.

Exception Handling and Override Workflows

Not every application fits neatly into automated rules. Exception handling allows human underwriters to override automated decisions when circumstances warrant it.

interface UnderwritingOverride {
  id: string;
  applicationId: string;
  originalDecision: UnderwritingDecision;
  overriddenOutcome: "approved" | "declined" | "conditionally_approved";
  justification: string;
  overriddenBy: string;
  approvedBy?: string;
  status: "pending_approval" | "approved" | "rejected";
  createdAt: Date;
  resolvedAt?: Date;
}
 
class OverrideWorkflow {
  constructor(
    private readonly overrideRepo: OverrideRepository,
    private readonly notificationService: NotificationService
  ) {}
 
  async requestOverride(
    applicationId: string,
    originalDecision: UnderwritingDecision,
    overriddenOutcome: UnderwritingOverride["overriddenOutcome"],
    justification: string,
    requestedBy: string
  ): Promise<UnderwritingOverride> {
    const override: UnderwritingOverride = {
      id: crypto.randomUUID(),
      applicationId,
      originalDecision,
      overriddenOutcome,
      justification,
      overriddenBy: requestedBy,
      status: "pending_approval",
      createdAt: new Date(),
    };
 
    await this.overrideRepo.save(override);
 
    // Overrides to approve a previously declined application
    // require a second-level review
    if (
      originalDecision.outcome === "declined" &&
      overriddenOutcome === "approved"
    ) {
      await this.notificationService.notifySeniorUnderwriter(override);
    }
 
    return override;
  }
 
  async resolveOverride(
    overrideId: string,
    resolution: "approved" | "rejected",
    resolvedBy: string
  ): Promise<UnderwritingOverride> {
    const override = await this.overrideRepo.findById(overrideId);
    if (!override) throw new Error("Override not found");
    if (override.status !== "pending_approval") {
      throw new Error("Override already resolved");
    }
 
    override.status = resolution;
    override.approvedBy = resolvedBy;
    override.resolvedAt = new Date();
 
    await this.overrideRepo.save(override);
    return override;
  }
}

A practical tip: track override rates as a key performance indicator. A high override rate may indicate that the rule set is too restrictive or that certain borrower segments are not well served by the current policy. This data feeds back into policy tuning.

Policy Versioning and A/B Testing

Credit policies evolve continuously. New regulations, changing market conditions, and portfolio performance data all drive policy adjustments. A versioned policy system allows lending teams to make changes safely.

class PolicyManager {
  constructor(private readonly repository: RuleSetRepository) {}
 
  async getActivePolicy(productType: string): Promise<RuleSet> {
    const now = new Date();
    const policies = await this.repository.findByProductType(productType);
 
    const active = policies
      .filter((p) => p.effectiveDate <= now)
      .filter((p) => !p.expirationDate || p.expirationDate > now)
      .sort((a, b) => b.version - a.version);
 
    if (active.length === 0) {
      throw new Error(`No active policy found for product type: ${productType}`);
    }
 
    return active[0];
  }
 
  async createNewVersion(
    existingPolicyId: string,
    modifications: Partial<RuleSet>,
    effectiveDate: Date
  ): Promise<RuleSet> {
    const existing = await this.repository.findById(existingPolicyId);
    if (!existing) throw new Error("Policy not found");
 
    const newVersion: RuleSet = {
      ...existing,
      ...modifications,
      id: crypto.randomUUID(),
      version: existing.version + 1,
      effectiveDate,
      expirationDate: undefined,
    };
 
    // Set expiration on the old version
    existing.expirationDate = effectiveDate;
    await this.repository.save(existing);
    await this.repository.save(newVersion);
 
    return newVersion;
  }
 
  async simulatePolicy(
    ruleSet: RuleSet,
    historicalApplications: Record<string, unknown>[]
  ): Promise<PolicySimulationResult> {
    const engine = new UnderwritingEngine(new RuleEvaluator());
    let approved = 0;
    let declined = 0;
    let referred = 0;
    let conditioned = 0;
 
    for (const appData of historicalApplications) {
      const decision = engine.execute("simulation", ruleSet, appData);
      switch (decision.outcome) {
        case "approved": approved++; break;
        case "declined": declined++; break;
        case "referred": referred++; break;
        case "conditionally_approved": conditioned++; break;
      }
    }
 
    const total = historicalApplications.length;
    return {
      totalApplications: total,
      approvalRate: approved / total,
      declineRate: declined / total,
      referralRate: referred / total,
      conditionalRate: conditioned / total,
    };
  }
}
 
interface PolicySimulationResult {
  totalApplications: number;
  approvalRate: number;
  declineRate: number;
  referralRate: number;
  conditionalRate: number;
}

The simulation capability is indispensable. Before activating a new policy version, the lending team can replay historical applications through the proposed rules to understand the impact on approval rates, referral volumes, and decline reasons. This reduces the risk of unintended consequences when policy changes go live.

Conclusion

A well-designed underwriting automation system transforms credit policy from scattered procedural code into a declarative, versionable, and testable configuration layer. The rule engine pattern enables lending teams to iterate on policies without engineering deployments, while the override workflow preserves the human judgment necessary for edge cases.

The key architectural principles are: use recursive condition types for maximum expressiveness, support multiple evaluation strategies for different policy needs, maintain a complete audit trail of every decision and override, and always provide simulation capabilities before activating policy changes. These patterns allow a lending platform to automate the vast majority of underwriting decisions while maintaining the control and transparency that regulators and risk managers require.

Related Articles

technical

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.

12 min read
business

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.

11 min read
business

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.

9 min read