Skip to content
ExponaBLOG
NESTJS · DAY 090 / 100

Designing a lead assignment engine for NestJS developers

How I built automatic lead routing for a CRM.

Expona Admin9 min read

Designing a Lead Assignment Engine

I've built several CRMs over the past few years, and one pattern keeps surfacing: how you route incoming leads determines whether your sales team thrives or drowns. Designing a lead assignment engine isn't just about who gets the next prospect—it's about fairness, capacity awareness, and auditability so you can explain to your VP of Sales why lead X went to rep Y. In this post, I'll walk through how I built an automatic lead routing system that handles rules, strategies, and full observability.

The Assignment Flow

The core pipeline is simple in concept but requires careful orchestration. When a lead arrives—via webhook, API call, or form submission—it enters a queue where we evaluate rules, pick an assignment strategy, execute the assignment, notify stakeholders, and start tracking SLA compliance. Let me break down each step with a real example.

Imagine your company handles e-commerce and enterprise support. A lead arrives with source "enterprise_demo" from the Singapore region interested in your "Analytics" product. The engine first matches this lead against stored rules: maybe you have a rule that says "enterprise_demo leads from APAC go to the enterprise team." Once rules match, we determine strategy—do we round-robin among available reps, or send to whoever has the lightest workload? Then we assign, fire off notifications to Slack or email, log the decision, and start an SLA timer so we know if the lead gets touched within, say, 2 hours.

Here's a simplified data model to ground this:

typescript
interface Lead {
  id: string;
  source: string;
  region: string;
  product: string;
  email: string;
  createdAt: Date;
}

interface AssignmentRule {
  id: string;
  name: string;
  conditions: {
    source?: string[];
    region?: string[];
    product?: string[];
  };
  assignmentStrategy: 'round_robin' | 'least_loaded';
  targetTeamId: string;
  priority: number;
}

interface Assignment {
  id: string;
  leadId: string;
  repId: string;
  ruleId: string;
  strategy: string;
  reason: string;
  assignedAt: Date;
  slaDeadline: Date;
}

The reason field is crucial—it stores plain English explaining why this lead went to this rep, which helps your sales org understand routing and dispute decisions if needed.

Rules as Data

Hardcoding assignment logic is a trap I fell into early. Your first customer wants "all inbound leads from the US go to Sarah." Six months later, Sarah moves to enterprise accounts, and you need to redeploy. Instead, store rules in the database and let your ops team adjust them without touching code.

I typically structure rules with conditions (lead attributes to match) and actions (which team, which strategy). Rules have a priority so if multiple match, the highest-priority wins. Here's how I'd implement rule matching:

typescript
class RuleEngine {
  async findMatchingRule(lead: Lead): Promise {
    const rules = await this.ruleRepository.find({
      where: { active: true },
      order: { priority: 'DESC' },
    });

    for (const rule of rules) {
      if (this.matchesConditions(lead, rule.conditions)) {
        return rule;
      }
    }
    return null;
  }

  private matchesConditions(
    lead: Lead,
    conditions: AssignmentRule['conditions'],
  ): boolean {
    if (
      conditions.source &&
      !conditions.source.includes(lead.source)
    ) {
      return false;
    }
    if (
      conditions.region &&
      !conditions.region.includes(lead.region)
    ) {
      return false;
    }
    if (
      conditions.product &&
      !conditions.product.includes(lead.product)
    ) {
      return false;
    }
    return true;
  }
}

I'd expose this via an admin API endpoint that lets ops create, edit, and test rules. Testing is critical—before a rule goes live, run it against historical leads to see how many would have been routed differently.

Fairness: Round-Robin with Availability and Capacity Checks

Round-robin is the easiest fairness strategy, but it's naive if you ignore availability and current workload. Assigning a lead to a rep who's on vacation or already has 30 open leads defeats the purpose.

Here's my approach: maintain a pointer to track whose turn it is, but before assigning, verify the rep is active and under their capacity limit. If not, skip to the next available rep.

typescript
class RoundRobinStrategy {
  async assignLead(
    lead: Lead,
    teamId: string,
    ruleId: string,
  ): Promise {
    const team = await this.teamRepository.findOne(teamId);
    const activeReps = await this.repRepository.find({
      where: {
        teamId,
        status: 'active', // not on vacation, not inactive
      },
    });

    if (!activeReps.length) {
      throw new Error(`No active reps in team ${teamId}`);
    }

    let lastPointer = team.lastAssignedRepIndex || 0;
    let assigned = false;
    let finalRep: SalesRep;

    for (let i = 0; i < activeReps.length; i++) {
      const idx = (lastPointer + i) % activeReps.length;
      const rep = activeReps[idx];

      const openLeadCount = await this.leadRepository.count({
        where: { assignedRepId: rep.id, status: 'open' },
      });

      if (openLeadCount < rep.maxOpenLeads) {
        finalRep = rep;
        team.lastAssignedRepIndex = (idx + 1) % activeReps.length;
        assigned = true;
        break;
      }
    }

    if (!assigned) {
      throw new Error(
        'All reps at capacity—cannot assign lead',
      );
    }

    const assignment = await this.assignmentRepository.create({
      leadId: lead.id,
      repId: finalRep.id,
      ruleId,
      strategy: 'round_robin',
      reason: `Assigned via round-robin to ${finalRep.name} (open leads: ${await this.leadRepository.count({ where: { assignedRepId: finalRep.id, status: 'open' } })})`,
      assignedAt: new Date(),
      slaDeadline: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours
    });

    await this.teamRepository.save(team);
    return assignment;
  }
}

The key moves here: query active reps only, check open lead count, iterate with modulo arithmetic to find someone available, and update the pointer for next time. The reason field documents exactly why this decision was made.

Least-Loaded Strategy

Some teams prefer least-loaded over round-robin because it naturally balances workload. Pick the rep with the fewest open leads. It's simpler conceptually but can cause problems if one rep is much faster than others—they'll get hammered.

typescript
class LeastLoadedStrategy {
  async assignLead(
    lead: Lead,
    teamId: string,
    ruleId: string,
  ): Promise {
    const activeReps = await this.repRepository.find({
      where: { teamId, status: 'active' },
    });

    if (!activeReps.length) {
      throw new Error(`No active reps in team ${teamId}`);
    }

    const repLoads = await Promise.all(
      activeReps.map(async (rep) => ({
        rep,
        openCount: await this.leadRepository.count({
          where: { assignedRepId: rep.id, status: 'open' },
        }),
      })),
    );

    const leastLoaded = repLoads.reduce((prev, current) =>
      current.openCount < prev.openCount ? current : prev,
    );

    const assignment = await this.assignmentRepository.create({
      leadId: lead.id,
      repId: leastLoaded.rep.id,
      ruleId,
      strategy: 'least_loaded',
      reason: `Assigned via least-loaded to ${leastLoaded.rep.name} (open leads: ${leastLoaded.openCount})`,
      assignedAt: new Date(),
      slaDeadline: new Date(Date.now() + 2 * 60 * 60 * 1000),
    });

    return assignment;
  }
}

Audit Everything

You'll get questions. "Why did Sarah get this lead?" "Which rule routed this to the enterprise team?" "When was this assigned?" Comprehensive audit logs are non-negotiable for trust and debugging.

Every assignment record should capture: which lead, which rep, which rule matched, which strategy, a human-readable reason, timestamp, and SLA deadline. Store assignments in a separate, immutable table so you have a forever record of every routing decision.

I also log rule evaluations so you can trace which rules were considered and why they were rejected:

typescript
interface RuleEvaluationLog {
  id: string;
  leadId: string;
  ruleId: string;
  matched: boolean;
  reason: string;
  evaluatedAt: Date;
}

class AssignmentService {
  async assignLeadWithAudit(lead: Lead): Promise {
    const rules = await this.ruleEngine.getAllRules();
    const logs: RuleEvaluationLog[] = [];

    let matchedRule: AssignmentRule | null = null;

    for (const rule of rules) {
      const matched = this.ruleEngine.matchesConditions(
        lead,
        rule.conditions,
      );
      logs.push({
        id: v4(),
        leadId: lead.id,
        ruleId: rule.id,
        matched,
        reason: matched
          ? `Matched conditions: ${JSON.stringify(rule.conditions)}`
          : `Did not match; lead source=${lead.source}, region=${lead.region}, product=${lead.product}`,
        evaluatedAt: new Date(),
      });

      if (matched && !matchedRule) {
        matchedRule = rule;
      }
    }

    await this.ruleEvaluationLogRepository.save(logs);

    if (!matchedRule) {
      throw new Error('No matching rule found for lead');
    }

    const strategy =
      matchedRule.assignmentStrategy === 'round_robin'
        ? new RoundRobinStrategy(this.teamRepository, this.repRepository, this.leadRepository)
        : new LeastLoadedStrategy(this.teamRepository, this.repRepository, this.leadRepository);

    const assignment = await strategy.assignLead(
      lead,
      matchedRule.targetTeamId,
      matchedRule.id,
    );

    await this.notificationService.notifyRepAssignment(
      assignment,
    );
    await this.slaService.startSLATimer(assignment);

    return assignment;
  }
}

This logs every rule evaluation, not just the winner. When your team disputes a routing decision, you can pull up the full trace: "We evaluated 5 rules, rule #3 (enterprise APAC) matched first, so we used round-robin within that team."

Common Mistakes

  • Ignoring availability. Assigning to someone on vacation or inactive kills your SLA metrics. Always check status before assigning.
  • No capacity limits. Without a max open lead count per rep, your top performer gets buried. Set realistic caps based on your sales cycle.
  • Silent failures. If all reps are at capacity or no rule matches, don't silently drop the lead. Throw an error and alert ops immediately.
  • Not testing rule changes. Before a rule goes live, simulate it against the last 100 leads to see the impact. Rule changes can dramatically shift workload distribution.
  • Poor audit trails. Logging only the final assignment is not enough. Log every rule evaluation and every strategy decision so you can debug disputes.
  • Mixing database and in-memory state. If you cache the round-robin pointer in memory, a server restart resets it and breaks fairness. Always persist state to the database.

Key Takeaways

  • A lead assignment engine has five stages: rule matching, strategy selection, assignment, notification, and SLA tracking. Handle each carefully.
  • Store rules in the database with priority ordering so ops can adjust routing without deploys or code reviews.
  • Fairness requires checking rep availability and capacity before assigning. Round-robin with those constraints beats naive round-robin.
  • Every assignment decision must record why it happened: which rule, which strategy, rep availability, and workload. Log these in an immutable audit table.
  • Log rule evaluations, not just winners. When disputes arise, you need the full trace to explain the routing logic clearly.
  • Test rule changes against historical leads before enabling them in production so you understand the impact on workload distribution.
---

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles