Creating A Round Robin Assignment
Posted: December 09, 2021

Creating A Round Robin Assignment

Table of Contents:

  • Reviewing Round Robin Acceptance Criteria

  • Creating Thread-Safe Custom Settings

    • The Visitor Pattern
  • Encapsulating Sales Users For Assignment

  • Round Robin Assignment (Finally)

    • Beginning The Round Robin Implementation
    • Decoupling The Sales Assignment
    • Adding Fallback Users/Queues Into The Round Robin System
  • Round Robin Assignment: Wrapping Up

Assignment, be it for Leads, Cases, or custom objects in Salesforce, doesn’t always conform to out-of-the-box offerings. I’ve seen a few places where OmniChannel didn’t quite align with a company’s business rules; where Lead Assignment Rules didn’t offer the capabilities necessary to properly assign an owner. One common example of this is the so-called “round robin” assigner, where a company’s leads (or any other object) need to be assigned fairly between a number of sales reps. In this article, I’ll delve into how you can simply and effectively use Apex to create a round robin system. Along the way, we’ll cover a number of exciting topics, like thread safety, proper encapsulation, and more.

Reviewing Round Robin Acceptance Criteria

Let’s take a look at a simple ERD for a round robin assigner, using the Lead object as an example:

Round Robin Assignment ERD

We’ll want to consider a few things when it comes to assignment:

  • Will we need a flag to opt users in/out of assignment? Of course the User object has the IsActive flag, but typically another flag is needed to handle things like people being out of office
  • We need to track the assignment of users for each given sales process. This is one of the few places that (in my opinion), a List-based Custom Setting is still appropriate; we need to be able to update the current assignment on the fly, it needs to be globally accessible and memory/thread-safe 1 (more on pseudo-thread safety in this post), and we don’t really want a custom object just to store a single record. Using Custom Settings requires some additional considerations, particularly on the subject of testing — we’ll get more into this in a moment
  • Are there any special business processes which route Leads (in this example, but this could hold true for any record you’re interested in routing) to a subset of a team? For our example, we’ll cover assigning to two different teams of SDRs and BDRs … in an ideal world, we’re looking to encapsulate (see the Object Oriented Basics post for more on this concept) the users that it’s possible to route assignment through in an object. We’ll get into the how of this later on!

As is often the case with development, the decision to involve a certain technology (in this case, Custom Settings) into our solution means that that’s where we should start. The rest of the round robin assignment will require the ability to:

  • get the current assignment (for our purposes, we’ll be returning a User Id)
  • update the current assignment (once more, using a User Id)

You’ll note the distinct seperation of concerns here: we aren’t interested in fetching Users, nor are we interested in the business rules associated with how those users get fetched, or what it means to round robin assign — we’ll get to that later. Let’s dig in:

Creating Thread-Safe Custom Settings

In reviewing our requirements for our custom setting, we have two tasks ahead of us:

  • [] create a read-safe way of accessing the current assignment
  • [] create a write-safe way to update the current assignment

Let’s tackle read safety first.

We are assuming it’s possible for Lead assignment to occur concurrently, which means we want to protect the way our Custom Setting ends up being involved in the overall process. Let’s also assume we’re safe to call this Custom Setting RoundRobin__c, and that it will have an Index__c text field on it.

For read safety, we can’t simply rely on static variables; while static variables are cached across the entirety of a transaction, they’re not persisted across different transactions even when those transactions are running concurrently. To make concurrent reads safe, let’s return to a subject we’ve investigated previously in setTimeout() and Implementing Delays: the Cache.CacheBuilder interface. I’ll be using the AbstractCacheRepo shown off in that post — refer to the implementation there, or in the example repository:

public class RoundRobinRepository extends AbstractCacheRepo {
  private static Map<String, RoundRobin__c> CACHED_ASSIGNMENTS;

  // if your org makes use of Custom Settings in other places, it might be useful to
  // have the public method here conform to the API signature of a shared interface
  public RoundRobin__c getCurrentAssignment(String assignmentType) {
    if (CACHED_ASSIGNMENTS == null) {
      CACHED_ASSIGNMENTS = (Map<String, RoundRobin__c>) this.getFromCache();
    }
    return CACHED_ASSIGNMENTS.get(assignmentType);
  }

  protected override Object populateCache() {
    // from the AbstractCacheRepo
    return getRoundRobinRecords();
  }

  protected override String getCacheKey() {
    return 'RoundRobinRepo';
  }

  private static Map<String, RoundRobin__c> getRoundRobinRecords() {
    // custom setting maps that use the Map constructor get initialized
    // wth the Name field as their keys
    return new Map<String, RoundRobin__c>(RoundRobin__c.getAll());
  }
}

Since the CACHED_ASSIGNMENTS variable is static, it will be persisted between any instances of the RoundRobinRepository within the same transaction; since its underlying representation will be stored within the Platform Cache, the values will be persisted between concurrently running transactions (or loaded fresh from the cache, which for our purposes is fine). Truly concurrent transactions could end up getting back an instance of the RoundRobin__c setting and re-using the same pointer to the “next” Sales user incorrectly, but we’ll cover how to deal with collisions there in just a little bit.

Of course, when it comes to read safety, we’re not quite ready to call this work complete yet — now that we’ve got our basic method stubbed out, we can easily demonstrate why that’s the case with our first failing test:

@IsTest
private class RoundRobinRepositoryTests {
  @IsTest
  static void shouldSafelyReturnCurrentAssignment() {
    // again, if your org uses custom settings elsewhere
    // this return type could be an interface instead of the
    // concrete class
    RoundRobinRepository repo = new RoundRobinRepository();

    System.assertNotEquals(null, repo.getCurrentAssignment('someKey'));
  }
}

In an actual implementation, here’s where things would get interesting — if I were going to be releasing this functionality using an unlocked package, for example, this is where I would introduce Custom Metadata that would provide a mapping between the defaults for each kind of assignment type (in our example, a “regular” sales team, and a list of BDRs). I’ll leave that as an exercise for the readers, and couple the mapping into the implementation:

public class RoundRobinRepository extends AbstractCacheRepo {
  private static Map<String, RoundRobin__c> CACHED_ASSIGNMENTS;
  private static final String SENTINEL_USER_INDEX = getSentinelIndex();

  public RoundRobin__c getCurrentAssignment(String assignmentType) {
    if (CACHED_ASSIGNMENTS == null) {
      CACHED_ASSIGNMENTS = (Map<String, RoundRobin__c>) this.getFromCache();
    }
    if (CACHED_ASSIGNMENTS.containsKey(assignmentType == false)) {
      this.initializeAssignment(assignmentType);
    }
    return CACHED_ASSIGNMENTS.get(assignmentType);
  }

  protected override Object populateCache() {
    return getRoundRobinRecords();
  }

  protected override String getCacheKey() {
    return 'RoundRobinRepo';
  }

  private void initializeAssignment(String assignmentType) {
    CACHED_ASSIGNMENTS.put(
      assignmentType,
      new RoundRobin__c(
        Name = assignmentType,
        Index__c = SENTINEL_USER_INDEX
      )
    );
  }

  private static Map<String, RoundRobin__c> getRoundRobinRecords() {
    return new Map<String, RoundRobin__c>(RoundRobin__c.getAll());
  }

  private static String getSentinelIndex() {
    // here, we'll use the most basic User Id possible
    // note that while we're coupling our implementation to Users,
    // this could be tweaked to refer to any other kind of "owning" object
    return User.SObjectType.getDescribe().getKeyPrefix() + '0'.repeat(12);
  }
}

Just to be safe, let’s also back up a step and add another test:

@IsTest
static void baseIdShouldAlwaysBeLessThanAnExistingUserId() {
  // I don't *love* repeating logic, but I also don't think this really merits
  // raising the visibility of the "getSentinelIndex" method to @TestVisible
  // because I wouldn't realistically keep this test around - it's to prove a point
  Id baseId = User.SObjectType.getDescribe().getKeyPrefix() + '0'.repeat(12);
  Id firstUserId = [
    SELECT Id
    FROM User
    ORDER BY Id
    LIMIT 1
  ].Id;
  System.assertEquals(
    true, baseId < firstUserId,
    'Id equality ranking is no longer trustworthy'
  );
}

So our sentinel value will never be greater than an actual User record. That’s tangentially important (I promise — we’ll get there!), but not directly related to the task at hand. Returning to our list:

  • create a read-safe way of accessing the current assignment
  • [] create a write-safe way to update the current assignment

For what we’re discussing, read and write safety are fairly intertwined since we don’t want to write our currently read setting in the event that there’s a temporal collision. In other words, we don’t want to use the same Index__c between two (or more) transactions running at the same time — but we can’t detect the possibility of a collision until each instance of the RoundRobinRepository gets to the write stage. This will force us to open up some additional APIs within our repository, since the consumer (which hasn’t been shown yet) will need to know:

  • if an update has been successful or
  • if round robin assignment should be run again, which would mean
    • the cache needs to be refreshed
    • assignment needs to be re-run using the newly updated Index__c pointer
    • an update can finally occur in this thread

This is a good deal more complex than the relatively simple surface layer that we’ve already exposed; it’s also probably overkill for most applications. I came from e-commerce, though — a domain where Leads, in particular, can come in simultaneously in great quantities! Overkill or not, let’s do this … write 😁.

// in RoundRobinRepositoryTests
@IsTest
static void shouldRefreshCache() {
  RoundRobinRepository repo = new RoundRobinRepository();

  RoundRobin__c current = repo.getCurrentAssignment('someKey');
  current.Index__c = '0greaterThan';
  upsert current;

  repo.forceRefreshCache(); // we'll have to implement this

  RoundRobin__c refreshed = repo.getCurrentAssignment(current.Name);
  System.assertEquals(
    current.Index__c,
    refreshed.Index__c,
    'Cache should be refreshed!'
  );
}

And in RoundRobinRepository:

public void forceRefreshCache() {
  // from AbstractCacheRepo
  // I cheated earlier by making getRoundRobinRecords
  // a standalone method; I knew we would have to call it here
  CACHED_ASSIGNMENTS = getRoundRobinRecords();
  this.updateCache(CACHED_ASSIGNMENTS);
}

In order for the repository to be able to report whether or not it the cache needs to be refreshed, we’ll need to keep track of two things:

  • when each repository instance has been initialized
  • when each assignment record has last been updated — you can use the LastModifiedDate audit field for that, but I’ll be using a LastUpdated__c datetime field instead to make testing easier

Moving on to our next test:

// in RoundRobinRepositoryTests
@IsTest
static void shouldIndicateOnUpdateIfCacheHasBeenModified() {
  RoundRobinRepository repo = new RoundRobinRepository();

  RoundRobin__c current = repo.getCurrentAssignment('someKey');
  current.Index__c = '0greaterThan';
  current.LastUpdated__c = System.now().addSeconds(1);
  upsert current;

  // we will have to implement this method
  Boolean wasCommitSuccessful = repo.commitUpdatedAssignment(current);
  System.assertEquals(false, wasCommitSuccessful);
}

And then the implementation (you could return true in commitUpdatedAssignment by default to actually generate the failing test, if you’re following strict TDD):

// in RoundRobinRepository
public class RoundRobinRepository extends AbstractCacheRepo {
  private static Map<String, RoundRobin__c> CACHED_ASSIGNMENTS;
  private static final String SENTINEL_USER_INDEX = getSentinelIndex();

  public RoundRobin__c getCurrentAssignment(String assignmentType) {
    if (CACHED_ASSIGNMENTS == null) {
      CACHED_ASSIGNMENTS = this.getCachedAssignments();
    }
    if (CACHED_ASSIGNMENTS.containsKey(assignmentType) == false) {
      this.initializeAssignment(assignmentType);
    }

    return CACHED_ASSIGNMENTS.get(assignmentType);
  }

  public void forceRefreshCache() {
    CACHED_ASSIGNMENTS = getRoundRobinRecords();
    this.updateCache(CACHED_ASSIGNMENTS);
  }

  public Boolean commitUpdatedAssignment(RoundRobin__c updatedAssignment) {
    Boolean wasCommitSuccessful = true;
    Map<String, RoundRobin__c> currentCache = this.getCachedAssignments();
    if (currentCache.containsKey(updatedAssignment.Name) &&
      currentCache.get(updatedAssignment.Name).LastUpdated__c >
      CACHED_ASSIGNMENTS.get(updatedAssignment.Name).LastUpdated__c) {
      updatedAssignment = currentCache.get(updatedAssignment.Name);
      wasCommitSuccessful = false;
    } else {
      // this line wouldn't be necessary if we were using LastModifiedDate
      updatedAssignment.LastUpdated__c = System.now();
      upsert updatedAssignment;
    }

    CACHED_ASSIGNMENTS.put(updatedAssignment.Name, updatedAssignment);
    return wasCommitSuccessful;
  }

  protected override Object populateCache() {
    return getRoundRobinRecords();
  }

  protected override String getCacheKey() {
    return 'RoundRobinRepo';
  }

  private Map<String, RoundRobin__c> getCachedAssignments() {
    return (Map<String, RoundRobin__c>) this.getFromCache();
  }

  private void initializeAssignment(String assignmentType) {
    CACHED_ASSIGNMENTS.put(
      assignmentType,
      new RoundRobin__c(
        Name = assignmentType,
        // some sentinel value
        LastUpdated__c = Datetime.newInstanceGmt(1970, 01, 01),
        Index__c = SENTINEL_USER_INDEX
      )
    );
  }

  private static Map<String, RoundRobin__c> getRoundRobinRecords() {
    return new Map<String, RoundRobin__c>(RoundRobin__c.getAll());
  }

  private static String getSentinelIndex() {
    return User.SObjectType.getDescribe().getKeyPrefix() + '0'.repeat(12);
  }
}
  • create a read-safe way of accessing the current assignment
  • create a write-safe way to update the current assignment

Whew. We have now reached the end of the repository’s logical domain. It can tell calling code lots of information about the state of any given assignment, and give calling code the means to respond to that information. It’s finally time for us to move on to the actual assignment code itself!

The Visitor Pattern

… or are we? It’s at this point that I’d like to talk a little bit about the Visitor pattern, which offers an alternative to what would otherwise be the approach taken by the calling code. To be clear — it’s not strictly necessary to implement this pattern for the example we are currently exploring, but it would yield immense benefits the very instant we had use of the RoundRobinRepository (or any other thread-safe repo like it) for another business need. Why? Because while we have all the tools necessary for the calling code to keep things thread-safe, we’ll have to tediously copy that layer — the one responsible for calling commitUpdatedAssignment, and forceRefreshCache — to all call sites for this code. That kind of copying and pasting necessity begs to be encapsulated so that we can avoid:

// example calling code
private final RoundRobinRepository repo = new RoundRobinRepository();
public void assignOwners(List<SObject> records) {
  RoundRobin__c currentAssignment = this.repo.getCurrentAssignment('Example');
  this.doAssignment(records, currentAssignment);
  if (this.repo.commitUpdatedAssignment(currentAssignment) == false) {
    this.repo.forceRefreshCache();
    // recurse until the assignment is actually using the most up-to-date
    this.assignOwners(records);
  }
}

private void doAssignment(List<SObject> records, RoundRobin__c currentAssignment) {
  // do stuff, then afterwards:
  Id lastOwnerId = records.isEmpty() ? null : (Id) records[records.size() - 1].get('OwnerId');
  if (lastOwnerId != null) {
    currentAssignment.Index__c = lastOwnerId;
  }
}

This is a very simple example, even as a prelude to what we’ll be covering next. It’s not always so easy to structure a class such that it’s easy to recursively call the method you need to. You also don’t have any guarantee with this approach that client code will take the right approach (the while loop). Instead, we can make the caller implement an interface:

public interface IThreadSafeCacheVisitor {
  String getVisitKey();
  void visitRecords(List<SObject> records, SObject currentAssignment)
}

public without sharing class RoundRobinAssigner implements IThreadSafeCacheVisitor {
  // we'll return to setting up the repo in the actual
  // round robin section, later on
  private final RoundRobinRepository repo = new RoundRobinRepository(new DML());

  public void assignOwners(List<SObject> records) {
    this.repo.accept(this, records);
  }

  public String getVisitKey() {
    return RoundRobinAssigner.class.getName();
  }

  public void visitRecords(List<SObject> records, SObject currentAssignment) {
    // actual assignment logic here
  }
}

// and then in RoundRobinRepository
public class RoundRobinRepository {
  // ...
  public void accept(IThreadSafeCacheVisitor visitor, List<SObject> records) {
    RoundRobin__c currentAssignment = this.getCurrentAssignment(visitor.getVisitKey());
    visitor.visitRecords(records, currentAssignment);
    if (this.commitUpdatedAssignment(currentAssignment) == false) {
      this.forceRefreshCache();
      this.accept(visitor, records);
    }
    currentAssignment.LastUpdated__c = System.now();
    upsert currentAssignment;
  }
}

Et voilà! We can now reduce the visibility for forceRefreshCache, commitUpdatedAssignment, and even getCurrentAssignment from public to private. The caller only needs to conform to the interface, and doesn’t need to trouble itself with managing how the repository gets the current RoundRobin__c record; it also doesn’t need to concern itself with what might end up being a recursive call to performing the actual assignment. This is known as double dispatch, due to both objects sending messages to each other, and it neatly allows us to separate concerns between the repository and other callers.

Since we’ll be reducing the visibility of the existing methods we were testing, we can scrap those test methods. Note how simple the test now becomes:

private static String cacheKey = 'a sales team';
@IsTest
static void shouldUpdateAssignment() {
  Datetime someTimeAgo = System.now().addDays(-3);
  upsert new RoundRobin__c(LastUpdated__c = someTimeAgo, Name = cacheKey);
  RoundRobinRepository repo = new RoundRobinRepository();

  repo.accept(new VisitorMock(), new List<SObject>());

  RoundRobin__c updatedAssignment = [SELECT LastUpdated__c FROM RoundRobin__c WHERE Name = :cacheKey];
  System.assertEquals(
    true,
    someTimeAgo < updatedAssignment.LastUpdated__c,
    'Cached record should have had its LastUpdated__c field updated properly: ' + updatedAssignment
  );
}

private class VisitorMock implements IThreadSafeCacheVisitor {
  public String getVisitKey() {
    return cacheKey;
  }
  public void visitRecords(List<SObject> records, SObject currentCacheRecord) {
    // this is a no-op
  }
}

Note that we could remove the SOQL/DML entirely from this particular unit test, as well, but the Cache.CacheBuilder interface that RoundRobinRepository implements through the AbstractCacheRepo requires a zero-argument constructor. I explain how you can use the Factory pattern & dependency injection to fully commit to Inversion of Control, or IoC (which allows you to easily continue to mock SOQL/DML even with zero-arg constructors), but that isn’t a topic we’ll cover here, beyond mentioning that a solution exists if you want to make this into a pure unit test!

Encapsulating Sales Users For Assignment

Remember when I said we’d get to the how of encapsulating users for assignment later on in this post? Well, that moment is now. Luckily, this is nowhere near as complex a subject as thread-safety when it comes to keeping track of the currently assigned user. For this example, I’ll be reusing code taken from the Repository pattern to speed up the process, but the theory of encapsulating SOQL queries behind strongly typed objects holds true as a useful pattern regardless of whether or not you use a strongly typed query builder.

Returning to the skeleton framework for the RoundRobinAssigner.assignOwners method I showed off just above, there’s enough there in that method signature to allow me to write the failing test that will drive out the remainder of this post. For now, we’re going to assume that there’s a custom field, IsRoundRobinActive__c that’s been added to the User object. We’re also going to assume that managements maintains the Department field on the User; this is how we’d like to differentiate between our BDRs and our standard Sales team. In pseudo-code, here’s what a test might look like for this:

@IsTest
private class RoundRobinAssignerTests {

  @IsTest
  static void shouldRoundRobinSalesLeads() {
    // setup three users - two in the department we'd like to assign to;
    // one in the department we'll ignore for now

    // create four leads

    // call assignment with the leads

    // validate that the two users from the department we want got the leads
    // split between them, and that no lead has the owner we want to ignore
    // as their Owner
  }
}

This is where the need for proper encapsulation — and taking the time to do it — really shines. By default, if we were to try to implement this pseudo-code in a scratch org, we’d run into issues, since scratch orgs can only have two active users at any one given time. Of course, there’s nothing strictly in the requirements of the functionality we’re trying to exercise that requires the User records to be active (there’s also nothing in the requirements that says “your tests should work in sandboxes and scratch orgs,” but we should take that as a given) — again, we’re relying on a newly created IsRoundRobinActive__c flag instead of the base IsActive flag so that people can configure things like people being out of office, but still — if we are to continue operating in a true unit test, we shouldn’t need to perform DML in order to get our data set up properly. With the repository pattern, we don’t have to:

// in RoundRobinAssignerTests
@IsTest
static void shouldRoundRobinSalesLeads() {
  // arrange
  List<User> users = new List<User> {
      new User(Department = 'SDR', IsRoundRobinActive__c = true),
      new User(Department = 'SDR', IsRoundRobinActive__c = true)
  };
  TestingUtils.generateIds(users);
  // see the repo for more info!
  // the TL;DR is that we want to hot-swap out the query
  // done at runtime with *only* the data we're going to
  // inject into our one-size-fits-all repository mock
  IRepository mockUserRepo = new RepoFactoryMock.RepoMock(
    users
  );

  List<Lead> leadsToAssign = new List<Lead> {
    new Lead(),
    new Lead(),
    new Lead(),
    new Lead()
  };

  // act
  new RoundRobinAssigner(mockUserRepo).assignOwners(leadsToAssign);

  // assert
  Integer firstInsideSalesUserAssignmentCount = 0;
  Integer secondInsideSalesUserAssignmentCount = 0;

  for (Lead assignedLead : leadsToAssign) {
    System.assertNotEquals(null, assignedLead.OwnerId, 'Assignment should have been run');
    if (assignedLead.OwnerId == users[0].Id) {
      firstInsideSalesUserAssignmentCount++;
    } else if (assignedLead.OwnerId == users[1].Id) {
      secondInsideSalesUserAssignmentCount++;
    }
  }
  System.assertEquals(
    2,
    firstInsideSalesUserAssignmentCount,
    'Leads should have been assigned equally'
  );
  System.assertEquals(
    2,
    secondInsideSalesUserAssignmentCount,
    'Leads should have been assigned equally'
  );
}

That’s the unit test we’d like to write, anyway — we’ll return to the implementation for round robin assignment once we review the acceptance criteria!

Round Robin Assignment (Finally)

With thread-safety and user-level encapsulation now thoroughly checked off ✅, it’s time to turn our attention to the process of how round robin assignment actually works. It’s taken 3431 words (and counting!) to get to this point, but we’ve covered a number of thoroughly interesting technical challenges along the way!

Let’s review the acceptance criteria for round robin assignment:

  • each type of assignment (again, in our example, an SDR and BDR team) should have a list of users associated with it
    • users will have a custom checkbox field, IsPartOfRoundRobin__c. This checkbox will determine two things:
      • if a Lead (or any other object) is currently assigned to a User whose IsPartOfRoundRobin__c is currently false, the record will have its owner re-assigned
      • only Users with IsPartOfRoundRobin__c flipped to true are eligible to have records assigned to them
    • since it might be possible for each given list of users to not have anybody with their IsPartOfRounbRobin__c flag active, we’ll also have either a fallback user or Queue. Note that for supported objects, use of a Queue, or a list of Queues, might also suffice for the larger “round robin” issue; I’ve seen too many instances where organizations wanted to track ownership at an individual level, however, to simply wave my hands and use Queues to “solve” the problem
  • when a given list of records is passed into the assigner, ownership should be assigned such that if the assigner reaches the end of the possible list of Users before running out of records to assign, it will start back over at the beginning of the list of possible owners until all records have been assigned an owner

We’ve seen the simplest possible unit test, which covers a wide variety of the conditions above — let’s return to the RoundRobinAssigner implementation now to begin stubbing some of this out.

Beginning The Round Robin Implementation

Not much needs to change in the RoundRobinAssigner in order to do so (I’ll note again that we won’t be covering Inversion of Control, or IoC, here; if you’re interested in IoC, I’d definitely recommend reading my articles on the Factory pattern and You Need A Strongly Typed Query Builder for more info on how to implement that). Instead, dependencies will be passed through the classic constructor injection point:

public without sharing class RoundRobinAssigner implements IThreadSafeCacheVisitor {
  private final RoundRobinRepository roundRobinRepo;
  private final IRepository userRepo;

  private static final Integer SENTINEL_INDEX = -1;
  private static final String OWNER_ID = 'OwnerId';

  public RoundRobinAssigner(IRepository userRepo) {
    this.roundRobinRepo = new RoundRobinRepository();
    this.userRepo = userRepo;
  }

  public void assignOwners(List<SObject> records) {
    this.roundRobinRepo.accept(this, records);
  }

  public String getVisitKey() {
    return RoundRobinAssigner.class.getName();
  }

  public void visitRecords(List<SObject> records, SObject currentCachedAssignment) {
    RoundRobin__c cachedAssignment = (RoundRobin__c) currentCachedAssignment;
    List<User> applicableUsers = this.getUsers('SDR');
    Integer currentUserIndex = this.getCurrentUserIndex(applicableUsers, cachedAssignment);
    for (SObject record : records) {
      Id ownerId = (Id) record.get(OWNER_ID);
      User nextUser = applicableUsers[currentUserIndex];
      if (ownerId == null) {
        record.put(OWNER_ID, nextUser.Id);
        cachedAssignment.Index__c = nextUser.Id;
        currentUserIndex = currentUserIndex == applicableUsers.size() - 1 ? 0 : currentUserIndex + 1;
      }
    }
  }

  private List<User> getUsers(String departmentType) {
    return this.userRepo.get(new List<Query>{ Query.equals(User.Department, departmentType), Query.equals(User.IsActive, true) });
  }

  private Integer getCurrentUserIndex(List<User> users, RoundRobin__c cachedAssignment) {
    Integer currentUserIndex = SENTINEL_INDEX;
    for (Integer index = 0; index < users.size(); index++) {
      User user = users[index];
      if (user.Id > cachedAssignment.Index__c) {
        currentUserIndex = index;
        break;
      }
    }
    if (currentUserIndex == SENTINEL_INDEX) {
      currentUserIndex = 0;
    }
    return currentUserIndex;
  }
}

Bang. The test passes! There are a couple of rough patches here — note that hard-coded “SDR” string, for one. We’ve also only covered the basic case, and though we have that string to keep in the back of our minds, we don’t have any obvious way to get rid of it at the moment and we still have acceptance criteria to fulfill … let’s file it away to examine later. In the meantime, let’s get another failing test going:

// in RoundRobinAssignerTests
@IsTest
static void shouldRoundRobinPreviouslyAssignedLeadsWhenTheirCurrentOwnerFlagIsInactive() {
  // arrange
  List<User> users = createUsersForDepartment('SDR');
  users[0].IsRoundRobinActive__c = false;
  IRepository mockUserRepo = new RepoFactoryMock.RepoMock(users);

  List<Lead> leadsToAssign = new List<Lead>{
    new Lead(OwnerId = users[0].Id),
    new Lead(OwnerId = users[0].Id),
    new Lead(),
    new Lead()
  };

  // act
  new RoundRobinAssigner(mockUserRepo).assignOwners(leadsToAssign);

  // assert
  for (Lead assignedLead : leadsToAssign) {
    System.assertEquals(users[1].Id, assignedLead.OwnerId);
  }
}

This one small change has a ripple effect within the RoundRobinAssigner:

public without sharing class RoundRobinAssigner implements IThreadSafeCacheVisitor {
  private final RoundRobinRepository roundRobinRepo;
  private final IRepository userRepo;

  private static final Integer SENTINEL_INDEX = -1;
  private static final String OWNER_ID = 'OwnerId';

  public RoundRobinAssigner(IRepository userRepo) {
    this.roundRobinRepo = new RoundRobinRepository();
    this.userRepo = userRepo;
  }

  public void assignOwners(List<SObject> records) {
    this.roundRobinRepo.accept(this, records);
  }

  public String getVisitKey() {
    return RoundRobinAssigner.class.getName();
  }

  public void visitRecords(List<SObject> records, SObject currentCachedAssignment) {
    RoundRobin__c cachedAssignment = (RoundRobin__c) currentCachedAssignment;
    List<User> applicableUsers = this.getUsers('SDR');
    Integer nextUserIndex = this.getNextUserIndex(applicableUsers, cachedAssignment);
    for (SObject record : records) {
      Id ownerId = (Id) record.get(OWNER_ID);
      User nextUser = applicableUsers[nextUserIndex];
      if (ownerId == null || this.isCurrentUserInactive(ownerId, applicableUsers)) {
        record.put(OWNER_ID, nextUser.Id);
        cachedAssignment.Index__c = nextUser.Id;
        nextUserIndex = this.advanceUserIndex(nextUserIndex, applicableUsers);
      }
    }
  }

  private List<User> getUsers(String departmentType) {
    return this.userRepo.get(
      new List<Query>{
        Query.equals(User.Department, departmentType),
        Query.equals(User.IsActive, true)
      }
    );
  }

  private Integer getNextUserIndex(List<User> users, RoundRobin__c cachedAssignment) {
    Integer currentUserIndex = SENTINEL_INDEX;
    for (Integer index = 0; index < users.size(); index++) {
      User user = users[index];
      if (user.Id > cachedAssignment.Index__c && user.IsRoundRobinActive__c) {
        currentUserIndex = index;
        break;
      }
    }
    if (currentUserIndex == SENTINEL_INDEX) {
      currentUserIndex = 0;
    }
    return currentUserIndex;
  }

  private Boolean isCurrentUserInactive(Id ownerId, List<User> users) {
    Boolean isCurrentUserInactive = true;
    for (User user : users) {
      if (ownerId == user.Id && user.IsRoundRobinActive__c) {
        isCurrentUserInactive = false;
        break;
      }
    }
    return isCurrentUserInactive;
  }

  private Integer advanceUserIndex(Integer nextUserIndex, List<User> users) {
    nextUserIndex = nextUserIndex == users.size() - 1 ? 0 : nextUserIndex + 1;
    if (users[nextUserIndex].IsRoundRobinActive__c == false) {
      nextUserIndex = this.advanceUserIndex(nextUserIndex++, users);
    }
    return nextUserIndex;
  }
}

Isn’t that funny? We got 80% of the functionality with 20% of the effort — now we’ve added quite a bit just in order to tick one more seemingly small box on our acceptance criteria. It’s not only the cognitive complexity of the class that’s ballooned — we’re also adding another method with the potential for iterating recursively through the applicable user list. This is a classic case where we have to pick and choose our battles — initially, it made sense to include the IsRoundRobinActive__c field in our query, and use code to react to whichever User was next in the list prior to letting it be used for assignment. Looking at the new size of the class, though, it’s not entirely clear that decision was a good one.

Let’s revisit our test under the assumption that the query itself will be the best place to check whether or not a user is currently in the round robin. Of course, we always have to make tradeoffs when coding. Especially for orgs with large numbers of Experience Cloud users, or huge swathes of employees in each department, it can be a suble balancing act when deciding whether it’s more performant to have a long-running, non-selective query operating on a large table, or to do the filtering in Apex. In this case, let’s assume it will be more performant to add the filter to our query.

Watch how this simplifies the implementation dramatically:

// in RoundRobinAssigner.cls
public void visitRecords(List<SObject> records, SObject currentCachedAssignment) {
  RoundRobin__c cachedAssignment = (RoundRobin__c) currentCachedAssignment;
  List<User> users = this.getUsers('SDR');
  Set<Id> activeOwnerIds = new Map<Id, User>(users).keySet();
  Integer nextUserIndex = this.getNextUserIndex(users, cachedAssignment);
  for (SObject record : records) {
    Id ownerId = (Id) record.get(OWNER_ID);
    User nextUser = users[nextUserIndex];
    if (ownerId == null || activeOwnerIds.contains(ownerId) == false) {
      record.put(OWNER_ID, nextUser.Id);
      cachedAssignment.Index__c = nextUser.Id;
      nextUserIndex = nextUserIndex == users.size() - 1 ? 0 : nextUserIndex + 1;
    }
  }
}

private List<User> getUsers(String departmentType) {
  return this.userRepo.get(
    new List<Query>{
      Query.equals(User.Department, departmentType),
      Query.equals(User.IsActive, true),
      Query.equals(User.IsRoundRobinActive__c, true)
    }
  );
}

private Integer getNextUserIndex(List<User> users, RoundRobin__c cachedAssignment) {
  Integer currentUserIndex = SENTINEL_INDEX;
  for (Integer index = 0; index < users.size(); index++) {
    User user = users[index];
    if (user.Id > cachedAssignment.Index__c) {
      currentUserIndex = index;
      break;
    }
  }
  if (currentUserIndex == SENTINEL_INDEX) {
    currentUserIndex = 0;
  }
  return currentUserIndex;
}

Using git diff --stat, we’ve removed 29 lines of code — a significant savings in complexity. There’s now only one place — in the getUsers query — where IsRoundRobinActive__c needs to be referenced. The test also gets simplified; I’ll also show off the benefit of using this particular version of the Repository pattern by showing you an updated version of the asserts:

@IsTest
static void shouldRoundRobinPreviouslyAssignedLeadsWhenTheirCurrentOwnerFlagIsInactive() {
  // arrange
  List<User> users = createUsersForDepartment('SDR');
  User inactiveUser = users.remove(0);

  IRepository mockUserRepo = new RepoFactoryMock.RepoMock(users);

  List<Lead> leadsToAssign = new List<Lead>{
    new Lead(OwnerId = inactiveUser.Id),
    new Lead(OwnerId = inactiveUser.Id),
    new Lead(),
    new Lead()
  };

  // act
  new RoundRobinAssigner(mockUserRepo).assignOwners(leadsToAssign);

  // assert
  for (Lead assignedLead : leadsToAssign) {
    System.assertEquals(users[0].Id, assignedLead.OwnerId);
  }
    // now we also can validate that IsRoundRobinActive__c was part of the query
  System.assertEquals(
    new List<Query>{
      Query.equals(User.Department, 'SDR'),
      Query.equals(User.IsActive, true),
      Query.equals(User.IsRoundRobinActive__c, true)
    },
    RepoFactoryMock.QueriesMade
  );
}

Hopefully the benefit of that last assert registers: we don’t have to test every iteration, exhaustively, for any given query; by using a strongly-typed query builder, we can instead validate that the query was formed correctly, and move on with our lives. It simplifies test setup and avoids the coupling of our tests to not only the database, but also to how SOQL works under the hood. When I am looking to unit test something, I’m not looking to verify that issuing a query with a restrictive where clause only returns those results — I’m looking to validate that the code operates properly on those results further downstream. Thinking in units helps us to properly imagine loosely coupled, modular pieces of a system (as it applies to your business, or the businesses of your customers).

Decoupling The Sales Assignment

Let’s return to that “SDR” string that was hard-coded, and to our original test for the RoundRobinAssigner. While it’s a unit test for the assigner, it’s also an integration test for RoundRobinRepository, and we need to add an assert to that effect:

@IsTest
static void shouldRoundRobinSalesLeads() {
  // arrange
  String assignmentType = 'SDR';
  List<User> users = createUsersForDepartment(assignmentType);
  // ...

  // now verify that the assignment index was updated
  RoundRobin__c cachedAssignment = [
    SELECT LastUpdated__c, Index__c
    FROM RoundRobin__c
    WHERE Name = :assignmentType
  ];
  System.assertEquals(
    users[1].Id,
    cachedAssignment.Index__c,
    'Last assigned user should match updated index'
  );
}

I’m curious — did you spot the issue? We don’t reach the assertion — it’s the SOQL call that fails, with System.QueryException: List has no rows for assignment to SObject. Returning to RoundRobinAssigner, recall that the name of each of the Custom Setting rows comes from one of the methods implemented to satisfy the IThreadSafeCacheVisitor interface:

public String getVisitKey() {
  return RoundRobinAssigner.class.getName();
}

But this simply won’t do! We face another curious choice, here, since we can:

  1. modify the assignOwners method so that it accepts a String as well as the records we are looking to assign, like public void assignOwners(List<SObject> records, String assignmentType)
  2. create different public visibility methods, like assignSDROwners and assignBDROwners
  3. subclass the assigner itself — this would call for having an abstract method: public abstract String getVisitKey() which the subclasses would then override and implement

Of these choices, the only one I would outright rule out is #2 — it’s a classic “leaky abstraction” which forces the assigner to broadcast the different things it knows about different departments; we want the assigner to be unopinionated and focused on what it means for owners to be assigned. While #3 is an attractive choice (and may be the preferred ones for codebases with a proper IoC/DI-based approach), I’ll stick with #1 here since it consumes the smallest amount of surface area and it’s spiritually very similar to #3. If there were many different code paths where assignment was becoming necessary, and too much in the way of hard-coded Strings as a result, I would lean more towards #3 in the long run.

Either way, there’s presumably business logic behind a company wanting to route Leads to different teams; that’s the part you don’t see here, since it falls on the calling code to determine what sort of assignment is needed. Again, the assigner itself should remain unopinionated on that front — its job is to divvy out owners fairly.

Let’s look at the changes we need to make to get that test passing:

public without sharing class RoundRobinAssigner implements IThreadSafeCacheVisitor {
  private final RoundRobinRepository roundRobinRepo;
  private final IRepository userRepo;

  private String assignmentType;

  private static final Integer SENTINEL_INDEX = -1;
  private static final String OWNER_ID = 'OwnerId';

  public RoundRobinAssigner(IRepository userRepo) {
    this.roundRobinRepo = new RoundRobinRepository();
    this.userRepo = userRepo;
  }

  public void assignOwners(List<SObject> records, String assignmentType) {
    this.assignmentType = assignmentType;
    this.roundRobinRepo.accept(this, records);
  }

  public String getVisitKey() {
    return this.assignmentType;
  }

  // ... etc
}

The test now also needs to be slightly tweaked:

// in RoundRobinAssignerTests.shouldRoundRobinSalesLeads()
String assignmentType = 'SDR';
List<User> users = createUsersForDepartment(assignmentType);
// ... etc
new RoundRobinAssigner(mockUserRepo).assignOwners(leadsToAssign, assignmentType);
// ...
// now verify that the assignment index was updated
RoundRobin__c cachedAssignment = [
  SELECT LastUpdated__c, Index__c
  FROM RoundRobin__c
  WHERE Name = :assignmentType
];
System.assertEquals(
  users[1].Id,
  cachedAssignment.Index__c,
  'Last assigned user should match updated index'
);

And the test now passes! Reviewing our requirements, there’s only one piece of functionality left to explore:

  • since it might be possible for each given list of users to not have anybody with their IsPartOfRounbRobin__c flag active, we’ll also have either a fallback user or Queue

Adding Fallback Users/Queues Into The Round Robin System

We’re very nearly there. Adding an additional requirement into how the list of users gets returned within the RoundRobinAssigner should immediately have you thinking about the fact that the userRepo instance variable is a dependency injected into the assigner. This, plus the fact that we only ever need the Id of users within the list that gets returned, means we have quite a bit of leeway when it comes to further encapsulation — in other words, since the assigner itself needs very little, we have the luxury of changing the type of the passed in dependency so that we can add in this additional requirement.

In looking at the change we just made (passing the assignment type into the RoundRobinAssigner), we know that the onus is on the calling code to also pass in the User repository (or to take advantage of IoC to encapsulate this particular piece as well, but the point stands). Also — not all cascading changes are bad ones. In contrast to the “one small change” we introduced when wanting to re-assign records when their current owner had been removed from the round robin temporarily, this next change reinforces the Single Responsible Principle nicely — note how the references to the User object within the RoundRobinAssigner disappear entirely:

public without sharing class RoundRobinAssigner implements IThreadSafeCacheVisitor {
  private final RoundRobinRepository roundRobinRepo;
  private final IAssignmentRepo assignmentRepo;

  private String assignmentType;

  private static final Integer SENTINEL_INDEX = -1;
  private static final String OWNER_ID = 'OwnerId';

  // we'll use this interface to abstract away the process
  // of retrieving qualified record owners
  public interface IAssignmentRepo {
    List<Id> getAssignmentIds(String assignmentType);
  }

  public RoundRobinAssigner(IAssignmentRepo assignmentRepo) {
    this.roundRobinRepo = new RoundRobinRepository();
    this.assignmentRepo = assignmentRepo;
  }

  public void assignOwners(List<SObject> records, String assignmentType) {
    this.assignmentType = assignmentType;
    this.roundRobinRepo.accept(this, records);
  }

  public String getVisitKey() {
    return this.assignmentType;
  }

  public void visitRecords(List<SObject> records, SObject currentCachedAssignment) {
    RoundRobin__c cachedAssignment = (RoundRobin__c) currentCachedAssignment;
    List<Id> assignmentIds = this.assignmentRepo.getAssignmentIds(this.assignmentType);
    Set<Id> activeAssignmentIds = new Set<Id>(assignmentIds);
    Integer nextAssignmentIndex = this.getNextAssignmentIndex(assignmentIds, cachedAssignment);
    for (SObject record : records) {
      Id ownerId = (Id) record.get(OWNER_ID);
      Id nextOwnerId = assignmentIds[nextAssignmentIndex];
      if (ownerId == null || activeAssignmentIds.contains(ownerId) == false) {
        record.put(OWNER_ID, nextOwnerId);
        cachedAssignment.Index__c = nextOwnerId;
        nextAssignmentIndex = nextAssignmentIndex == assignmentIds.size() - 1 ? 0 : nextAssignmentIndex + 1;
      }
    }
  }

  private Integer getNextAssignmentIndex(List<Id> assignmentIds, RoundRobin__c cachedAssignment) {
    Integer currentAssignmentIndex = SENTINEL_INDEX;
    for (Integer index = 0; index < assignmentIds.size(); index++) {
      Id assignmentId = assignmentIds[index];
      if (assignmentId > cachedAssignment.Index__c) {
        currentAssignmentIndex = index;
        break;
      }
    }
    if (currentAssignmentIndex == SENTINEL_INDEX) {
      currentAssignmentIndex = 0;
    }
    return currentAssignmentIndex;
  }
}

With the IAssignmentRepo interface now defined, we can create a new class:

public without sharing class SDRUserRepo implements RoundRobinAssigner.IAssignmentRepo {
  private final IRepository userRepo;

  public SDRUserRepo(IRepository userRepo) {
    this.userRepo = userRepo;
  }

  public List<Id> getAssignmentIds(String departmentType) {
    List<Id> assignmentIds = new List<Id>();
    for (User user : this.getUsers(departmentType)) {
      assignmentIds.add(user.Id);
    }
    return assignmentIds;
  }

  private List<User> getUsers(String departmentType) {
    return this.userRepo.get(
      new List<Query>{
        Query.equals(User.Department, departmentType),
        Query.equals(User.IsActive, true),
        Query.equals(User.IsRoundRobinActive__c, true)
      }
    );
  }
}

Let’s update the existing tests in RoundRobinAssignerTests:

// both tests get updated to include this line
RoundRobinAssigner.IAssignmentRepo repo = new SDRUserRepo(mockUserRepo);
new RoundRobinAssigner(repo).assignOwners(leadsToAssign, assignmentType);

Again, this is all perfectly compatible with the TDD lifecycle (for those interested in following along on that front). We went from a failing test, to a passing test, and now we’re refactoring. No functionality has changed; we’re just moving around the bits and pieces to facilitate our next failing test. Let’s create a new test class, SDRUserRepoTests:

@IsTest
private class SDRUserRepoTests {
  @IsTest
  static void shouldAssignFallbackUserWhenNoUsersReturned() {
    SDRUserRepo repo = new SDRUserRepo(new RepoFactoryMock.RepoMock());
    List<Id> assignmentIds = repo.getAssignmentIds('some department type');
    System.assertEquals(
      1,
      assignmentIds.size(),
      'fallback Id should have been included!'
    );
  }
}

All that’s left is to grab the Queue or fallback user Id. I’m going to go with something extremely simple here, with the understanding that nobody should ever do this in an actual org:

public without sharing class SDRUserRepo implements RoundRobinAssigner.IAssignmentRepo {
  private final IRepository userRepo;

  public SDRUserRepo(IRepository userRepo) {
    this.userRepo = userRepo;
  }

  public List<Id> getAssignmentIds(String departmentType) {
    List<Id> assignmentIds = new List<Id>();
    for (User user : this.getUsers(departmentType)) {
      assignmentIds.add(user.Id);
    }
    if (assignmentIds.isEmpty()) {
      assignmentIds.add(this.getFallbackUserId());
    }
    return assignmentIds;
  }

  private List<User> getUsers(String departmentType) {
    return this.userRepo.get(
      new List<Query>{
        Query.equals(User.Department, departmentType),
        Query.equals(User.IsActive, true),
        Query.equals(User.IsRoundRobinActive__c, true)
      }
    );
  }

  private Id getFallbackUserId() {
    // please never do this IRL
    return [SELECT Id FROM User LIMIT 1].Id;
  }
}

Why, after all this complexity, settle for a randomly selected user? Because every business will have different requirements. They might already have processes in place to identify fallback owners; that’s not an uncommon practice (which is why I bring it up at all, as part of this post). In your own implementations, you’ll have fun choices as far as how this Id gets identified. It might be the same across all different kinds of assignments … it might differ per department (in keeping in line with the rest of this example). It might come from a Custom Setting, from Custom Metadata … I’ve unfortunately even seen it come from a Custom Label.

Long story short — businesses define how to identify Queues/Users that end up in the fallback position. Proper encapsulation should make it possible to always be able to tweak this fallback without making code changes. You have the proper architecture in place, which prevents something like a fallback assignment User/Queue from pervading the codebase.

For example, I’ll show off the previously-undiscussed BDR part of this to demonstrate how a different process might react to having a fallback implementation:

// first we'll abstract the SRD repo:
public without sharing virtual class AssignmentRepo implements RoundRobinAssigner.IAssignmentRepo {

  // upgrading the visibility of this method
  protected virtual Id getFallbackUserId() {
    // ...
  }
}

// and now a new class:
public inherited sharing class BDRAssignmentRepo extends AssignmentRepo {
  public BDRAssignmentRepo(IRepository userRepo) {
    super(userRepo);
  }

  protected override Id getFallbackUserId() {
    // for BDRs they get ownership if nobody's in the queue
    return UserInfo.getUserId();
  }
}

Easy-peasy!

Round Robin Assignment: Wrapping Up

It’s nearly 5 years, to the date, since I last looked at round robin assignment. The original RoundRobinAssigner was developed in strict TDD fashion, paired programming with my mentor and a colleague who would become a great friend. I didn’t know, then, that the next two years would focus almost exclusively on paired programming — greatly accelerating the speed at which my own nascent programming capabilities were developed. Aside from thinking about the spirit of fairness — giving users ownership over the records they deserve — this was written without reference; the concepts of thread-safety and encapsulation of User “queues” were written on-the-fly without the original source code. This is one of many reasons why you should revisit old code: the benefit of hindsight frequently leads to cleaner implementations that address edge-cases you might not even have been aware of the first time around.

I’ve also been reading the newly updated Advanced Apex Programming by Dan Appleman. While the 3rd version of that book was, when I was first starting out, an incredibly useful jumping off point into “thinking in Salesforce,” I daresay that the caching implementation we’ve discussed here goes quite a bit beyond the static and Platform Cache-based caching discussed in that book. The thread-safe implementation in particular is something I’m hopeful will prove useful to you and provide value in your own journeys within the platform.

Anyway — please make sure to check out the code in the example repository if you’re interested in seeing the progression over time as this article was written. The vast majority of this article was written prior to producing the different code examples; I don’t always do this while writing (sometimes I write the code first, then publish an article about the findings), but here I was really looking to challenge my own memory of the prior round robin assignment I’d worked on, and see if I could improve on that at all. As I said — I’m glad I did, as the thread-safe part of this article came out of thinking about concurrency, which might not have happened if I had been heads-down coding. Thanks for reading, and I hope you enjoyed!



  1. Thread safety is of particular importance to us when it comes to assignment because we want assignment to be fair. What that means is that given a number of parallel processes (which, in this specific example, would involve leads being created or updated simultaneously), we want to be able to both safely access the current assignment (read safety) and we want to avoid multiple concurrent commits (write safety) for the current assignment

    ↩ go back from whence you came!
In the past three years, hundreds of thousands of you have come to read & enjoy the Joys Of Apex. Over that time period, I've remained staunchly opposed to advertising on the site, but I've made a Patreon account in the event that you'd like to show your support there. Know that the content here will always remain free. Thanks again for reading — see you next time!