Building a Personalization Engine

A technical guide to designing and implementing a real-time personalization engine in TypeScript for CRM platforms, covering recommendation algorithms, feature stores, and A/B testing integration.

technical9 min readBy Klivvr Engineering
Share:

Personalization is the difference between a CRM that stores data and a CRM that uses data. When a customer logs into their banking app and sees a generic dashboard, the CRM has failed them. When they see a savings goal they set last week, a reminder about an upcoming bill, and a credit product recommendation that actually matches their financial profile, the CRM is doing its job. At Klivvr, CVM Nova's personalization engine is the layer that transforms customer data into contextually relevant experiences.

This article covers the architecture and implementation of a TypeScript-based personalization engine, from feature assembly to recommendation scoring to real-time delivery.

Personalization Architecture Overview

A personalization engine has three core responsibilities: understand the customer (feature assembly), decide what to show them (scoring and ranking), and deliver the decision in real time (serving). Each layer has distinct performance characteristics and data requirements.

interface PersonalizationRequest {
  customerId: string;
  context: RequestContext;
  slots: PersonalizationSlot[];
}
 
interface RequestContext {
  channel: "app" | "web" | "email" | "push";
  timestamp: Date;
  deviceType?: string;
  location?: { lat: number; lng: number };
  sessionId?: string;
}
 
interface PersonalizationSlot {
  slotId: string;
  slotType: "product-recommendation" | "content" | "offer" | "next-best-action";
  maxItems: number;
  filters?: Record<string, unknown>;
}
 
interface PersonalizationResponse {
  customerId: string;
  decisions: SlotDecision[];
  requestDurationMs: number;
  modelVersion: string;
}
 
interface SlotDecision {
  slotId: string;
  items: RankedItem[];
}
 
interface RankedItem {
  itemId: string;
  itemType: string;
  score: number;
  explanation: string;
  metadata: Record<string, unknown>;
}

The request-response contract is designed for flexibility. A single personalization request can fill multiple "slots" — a product recommendation carousel, a hero banner, and a next-best-action prompt — in one round trip. Each slot specifies what type of content it needs and how many items. The response includes scores and explanations for every recommendation, enabling downstream logging, debugging, and transparency features.

The Feature Store

Every recommendation starts with features — computed attributes that describe the customer's behavior, preferences, and context. The feature store is the system that computes, stores, and serves these features with low latency.

interface CustomerFeatures {
  customerId: string;
  computedAt: Date;
 
  // Behavioral features
  transactionCount30d: number;
  averageTransactionValue30d: number;
  distinctMerchantCategories30d: number;
  lastLoginDaysAgo: number;
  appSessionsPerWeek: number;
 
  // Financial features
  totalBalance: number;
  savingsRate: number;
  creditUtilization: number;
 
  // Engagement features
  emailOpenRate: number;
  pushNotificationClickRate: number;
  featureAdoptionScore: number;
 
  // Lifecycle features
  tenureDays: number;
  segment: string;
  predictedChurnProbability: number;
  predictedCLV: number;
}
 
class FeatureStore {
  private cache: Map<string, CustomerFeatures> = new Map();
  private readonly ttlMs = 5 * 60 * 1000; // 5 minutes
 
  async getFeatures(customerId: string): Promise<CustomerFeatures> {
    const cached = this.cache.get(customerId);
    if (cached && Date.now() - cached.computedAt.getTime() < this.ttlMs) {
      return cached;
    }
 
    const features = await this.computeFeatures(customerId);
    this.cache.set(customerId, features);
    return features;
  }
 
  private async computeFeatures(customerId: string): Promise<CustomerFeatures> {
    // Parallel fetch from multiple data sources
    const [behavioral, financial, engagement, lifecycle] = await Promise.all([
      this.fetchBehavioralFeatures(customerId),
      this.fetchFinancialFeatures(customerId),
      this.fetchEngagementFeatures(customerId),
      this.fetchLifecycleFeatures(customerId),
    ]);
 
    return {
      customerId,
      computedAt: new Date(),
      ...behavioral,
      ...financial,
      ...engagement,
      ...lifecycle,
    };
  }
 
  private async fetchBehavioralFeatures(
    customerId: string
  ): Promise<Partial<CustomerFeatures>> {
    // Reads from pre-aggregated behavioral metrics table
    return {};
  }
 
  private async fetchFinancialFeatures(
    customerId: string
  ): Promise<Partial<CustomerFeatures>> {
    // Reads from account summary materialized view
    return {};
  }
 
  private async fetchEngagementFeatures(
    customerId: string
  ): Promise<Partial<CustomerFeatures>> {
    // Reads from engagement tracking system
    return {};
  }
 
  private async fetchLifecycleFeatures(
    customerId: string
  ): Promise<Partial<CustomerFeatures>> {
    // Reads from segmentation and CLV models
    return {};
  }
}

The feature store uses a two-layer architecture. The "offline" layer computes features from the data warehouse on a schedule — nightly for slow-moving features like CLV, hourly for moderately dynamic features like transaction counts. The "online" layer caches these pre-computed features and enriches them with real-time signals at serving time — the customer's current session context, their most recent action, and any in-flight events.

The five-minute TTL on the cache is a trade-off between freshness and latency. For most recommendation scenarios, features that are a few minutes stale are perfectly acceptable. The critical optimization is the parallel fetch pattern in computeFeatures — assembling features from four independent data sources concurrently rather than sequentially cuts latency by 70% in our benchmarks.

Scoring and Ranking

With features assembled, the next step is scoring candidate items against the customer's profile. CVM Nova supports multiple scoring strategies depending on the slot type and the available data.

interface ScoringStrategy {
  name: string;
  score(customer: CustomerFeatures, item: CandidateItem): number;
}
 
interface CandidateItem {
  id: string;
  type: string;
  features: Record<string, number>;
  eligibilityRules: EligibilityRule[];
}
 
interface EligibilityRule {
  field: string;
  operator: "gt" | "lt" | "eq" | "in";
  value: unknown;
}
 
class CollaborativeFilteringScorer implements ScoringStrategy {
  name = "collaborative-filtering";
  private customerVectors: Map<string, number[]>;
  private itemVectors: Map<string, number[]>;
 
  constructor(
    customerVectors: Map<string, number[]>,
    itemVectors: Map<string, number[]>
  ) {
    this.customerVectors = customerVectors;
    this.itemVectors = itemVectors;
  }
 
  score(customer: CustomerFeatures, item: CandidateItem): number {
    const customerVec = this.customerVectors.get(customer.customerId);
    const itemVec = this.itemVectors.get(item.id);
 
    if (!customerVec || !itemVec) return 0;
 
    return this.cosineSimilarity(customerVec, itemVec);
  }
 
  private cosineSimilarity(a: number[], b: number[]): number {
    let dotProduct = 0;
    let normA = 0;
    let normB = 0;
 
    for (let i = 0; i < a.length; i++) {
      dotProduct += a[i] * b[i];
      normA += a[i] * a[i];
      normB += b[i] * b[i];
    }
 
    const denominator = Math.sqrt(normA) * Math.sqrt(normB);
    return denominator === 0 ? 0 : dotProduct / denominator;
  }
}
 
class RuleBasedScorer implements ScoringStrategy {
  name = "rule-based";
  private rules: ScoringRule[];
 
  constructor(rules: ScoringRule[]) {
    this.rules = rules;
  }
 
  score(customer: CustomerFeatures, item: CandidateItem): number {
    let totalScore = 0;
    for (const rule of this.rules) {
      if (rule.condition(customer, item)) {
        totalScore += rule.weight;
      }
    }
    return totalScore;
  }
}
 
interface ScoringRule {
  name: string;
  weight: number;
  condition: (customer: CustomerFeatures, item: CandidateItem) => boolean;
}

Collaborative filtering works well when you have sufficient interaction data — customers who adopted product A also adopted product B. The rule-based scorer is a fallback for new products or thin data situations. In practice, CVM Nova blends both approaches, using a weighted combination that gradually shifts from rule-based to model-based as the product accumulates interaction data.

Eligibility Filtering and Business Rules

Not every item is appropriate for every customer. Regulatory constraints, product eligibility requirements, and business rules must be applied before scoring. A credit card recommendation is irrelevant to a customer who already has one. A high-risk investment product must not be shown to a customer whose risk profile has not been assessed.

class PersonalizationPipeline {
  private featureStore: FeatureStore;
  private scorers: Map<string, ScoringStrategy>;
  private candidateStore: CandidateStore;
 
  constructor(
    featureStore: FeatureStore,
    scorers: Map<string, ScoringStrategy>,
    candidateStore: CandidateStore
  ) {
    this.featureStore = featureStore;
    this.scorers = scorers;
    this.candidateStore = candidateStore;
  }
 
  async personalize(request: PersonalizationRequest): Promise<PersonalizationResponse> {
    const startTime = Date.now();
    const features = await this.featureStore.getFeatures(request.customerId);
 
    const decisions: SlotDecision[] = [];
 
    for (const slot of request.slots) {
      const candidates = await this.candidateStore.getCandidates(slot.slotType);
 
      // Step 1: Filter by eligibility
      const eligible = candidates.filter((item) =>
        this.isEligible(features, item)
      );
 
      // Step 2: Score remaining candidates
      const scorer = this.scorers.get(slot.slotType);
      if (!scorer) continue;
 
      const scored = eligible.map((item) => ({
        item,
        score: scorer.score(features, item),
      }));
 
      // Step 3: Rank and select top items
      scored.sort((a, b) => b.score - a.score);
      const topItems = scored.slice(0, slot.maxItems);
 
      decisions.push({
        slotId: slot.slotId,
        items: topItems.map(({ item, score }) => ({
          itemId: item.id,
          itemType: item.type,
          score,
          explanation: this.generateExplanation(features, item, score),
          metadata: item.features,
        })),
      });
    }
 
    return {
      customerId: request.customerId,
      decisions,
      requestDurationMs: Date.now() - startTime,
      modelVersion: "v2.3.1",
    };
  }
 
  private isEligible(customer: CustomerFeatures, item: CandidateItem): boolean {
    return item.eligibilityRules.every((rule) => {
      const customerValue = (customer as Record<string, unknown>)[rule.field];
      switch (rule.operator) {
        case "gt": return (customerValue as number) > (rule.value as number);
        case "lt": return (customerValue as number) < (rule.value as number);
        case "eq": return customerValue === rule.value;
        case "in": return (rule.value as unknown[]).includes(customerValue);
        default: return false;
      }
    });
  }
 
  private generateExplanation(
    customer: CustomerFeatures,
    item: CandidateItem,
    score: number
  ): string {
    // Generate a human-readable explanation for why this item was recommended
    return `Recommended based on customer segment "${customer.segment}" with confidence ${(score * 100).toFixed(1)}%`;
  }
}
 
interface CandidateStore {
  getCandidates(slotType: string): Promise<CandidateItem[]>;
}

The pipeline follows a filter-score-rank pattern. Eligibility filtering is applied first to reduce the candidate set before expensive scoring operations. This matters when you have thousands of potential items but only need the top five.

A/B Testing Integration

Personalization without measurement is guesswork. CVM Nova integrates A/B testing at the personalization layer so that every algorithm change can be evaluated rigorously.

interface Experiment {
  id: string;
  name: string;
  status: "running" | "paused" | "completed";
  variants: ExperimentVariant[];
  trafficAllocation: Map<string, number>; // variantId -> percentage
}
 
interface ExperimentVariant {
  id: string;
  name: string;
  scorerOverride?: string;
  configOverrides?: Record<string, unknown>;
}
 
class ExperimentAssigner {
  assign(customerId: string, experiment: Experiment): ExperimentVariant {
    // Deterministic assignment based on customer ID hash
    const hash = this.hashCode(customerId + experiment.id);
    const bucket = Math.abs(hash) % 100;
 
    let cumulative = 0;
    for (const [variantId, percentage] of experiment.trafficAllocation) {
      cumulative += percentage;
      if (bucket < cumulative) {
        return experiment.variants.find((v) => v.id === variantId)!;
      }
    }
 
    return experiment.variants[0];
  }
 
  private hashCode(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash |= 0;
    }
    return hash;
  }
}

The experiment assigner uses deterministic hashing so that a customer always sees the same variant across sessions. The personalization pipeline checks for active experiments on each request and applies variant-specific overrides to the scoring strategy or configuration. Every personalization response includes the experiment and variant IDs, enabling downstream analytics to attribute outcomes to specific algorithm variants.

Conclusion

A personalization engine is not a single algorithm — it is a system that connects customer understanding to real-time decision-making. The architecture described here — a feature store for customer intelligence, pluggable scoring strategies, eligibility filtering for compliance, and integrated experimentation — provides the scaffolding for personalization that is both sophisticated and maintainable.

The most common mistake in building personalization systems is optimizing the algorithm before instrumenting the pipeline. Start by measuring what you currently show customers and how they respond. Then introduce simple rule-based personalization and measure the lift. Only after you have proven that personalization creates measurable value should you invest in collaborative filtering, deep learning, or other advanced techniques. The feature store and serving infrastructure you build early will support any algorithm you plug in later — but the measurement discipline must come first.

Related Articles

technical

Real-Time Customer Profiles with Event Streaming

A technical guide to building real-time customer profile systems using event streaming in TypeScript, covering event-driven architecture, stream processing, profile materialization, and consistency guarantees.

11 min read
business

Customer Engagement Metrics That Matter

A practical guide to defining, measuring, and acting on customer engagement metrics in CRM platforms, with a focus on metrics that drive retention and revenue in fintech.

11 min read
business

Data-Driven CRM: Strategy and Implementation

A strategic guide to building and operating a data-driven CRM practice, covering organizational alignment, data governance, analytics maturity models, and practical implementation roadmaps.

9 min read