Salesforce Developers Blog

Exploring A Combined Async Apex Framework

Avatar for James SimoneJames Simone
Which async Apex framework should you be using? This blog shows a solution that automatically chooses Batchable or Queueable Apex for you.
Exploring A Combined Async Apex Framework
February 07, 2023
Listen to this article
0:00 / 0:00

Batchable and Queueable are the two predominant async frameworks available to developers on the Salesforce Platform. When working with records, you may find yourself wondering which one should you be using. In this post, we’ll present an alternative solution that automatically chooses the correct option between the Batchable and Queueable Apex frameworks — leaving you free to focus on the logic you need to implement instead of which type of asynchronous execution is best.

Let’s walk through an approach that combines the best of both worlds. Both Batchable and Queueable are frequently used to:

  • Perform API callouts (as callouts are not allowed within synchronous triggerable code or directly within scheduled jobs)
  • Process data (which it wouldn’t be possible to work with when synchronously calling code due to things like Salesforce limits)

That being said, there are some interesting distinctions (that you may already be familiar with) which create obvious pros and cons when using either of the two frameworks.

Batchable Apex is:

  • Slower to start up, slower to move between Batchable chunks
  • Capable of querying up to 50 million records in its start method
  • Can only have five batch jobs actively working at any one given time
  • Can maintain a queue of batch jobs to start up when the five concurrent batch jobs are busy processing, but there can only ever be a max of 100 batch jobs in the flex queue

Queueable Apex is:

  • Quick to execute and quick to implement
  • Still subject to the Apex query row limit of 50,000 records
  • Can have up to 50 queueable apex jobs started from within a synchronous transaction
  • Can have only 1 queueable job be enqueued once you’re already in an asynchronous transaction

These pros and cons represent a unique opportunity to abstract away how an asynchronous process is defined and to create something reusable, regardless of how many records you need to act on.

Let’s look at an example implementation, and then at exactly how that abstraction will work.

First, an example of the design in usage

This example assumes that you’re working with a B2C Salesforce org where it’s important for the Account Name to always match that of the contact, and there can only ever be one Contact per Account. Notice how in our example ContactAsyncProcessor, the only logic that needs to exist is exactly associated with this business rule:

1public class ContactAsyncProcessor extends AsyncProcessor {
2  protected override void innerExecute(List<SObject> records) {
3    Map<Id, Account> accountsToUpdate = new Map<Id, Account>();
4
5    for (Contact con : (List<SObject>) records) {
6      accountsToUpdate.put(
7        con.AccountId,
8        new Account(
9          Id = con.AccountId,
10          Name = con.FirstName + ' ' + con.LastName
11        )
12      );
13    }
14
15    update accountsToUpdate.values();
16  }
17}
18
19// and then in usage
20new ContactAsnycProcessor()
21    .get('SELECT AccountId, FirstName, LastName FROM Contact')
22    .kickoff();

Of course, this is a very simple example — it doesn’t show things like the Contact.AccountId being null, handling for middle names, and more. This example does show off how subclassing can help to simplify code. Here, you don’t need to worry how many results are returned by the example query, or whether or not you should be using a Batchable or Queueable implementation — you can simply focus on the business rules.

What does that AsyncProcessor parent class end up looking like? Let’s take a look at what’s going on behind the scenes.

Creating a shared asynchronous processor

To start off, there are some interesting technical limitations that we need to be mindful of when looking to consolidate the Batchable and Queueable interfaces:

  • A batch class must be an outer class. It’s valid syntax to declare an inner class as Batchable, but trying to execute an inner class through Database.executeBatch will lead to an exception being thrown.
    • This async exception will only surface in logs and won’t be returned directly to the caller in a synchronous context, which can be very misleading since execution won’t halt as you might expect with a traditional exception
  • A queueable class can be an inner class, but an outer class that implements Database.Batchable and Database.Stateful can’t also implement System.Queueable.

You want this framework to be flexible and to scale without having to make any changes to it. It should be capable of:

  1. Taking a query or a list of records.
  2. Assessing how many records are part of the query or list.
  3. Checking if you’re below a certain threshold — which subclasses should be able to modify — start a Queueable. Otherwise, start a Batchable.

This diagram shows what needs to happen synchronously versus asynchronously:

process mapping diagram outlining the basic framework
These limitations can help to inform the overall design of the shared abstraction. For instance, you should have a way to interact with this class before it starts processing records asynchronously — this is the perfect place for an interface.

1public interface Process {
2  String kickoff();
3}

Since the Batchable class needs to be the outer class, you can first implement Process there.

1public abstract without sharing class AsyncProcessor implements Database.Batchable, Database.RaisesPlatformEvents, Process {
2  private static final String FALLBACK_QUERY = 'SELECT Id FROM Organization';
3
4  private Boolean hasBeenEnqueuedAsync = false;
5  private Boolean getWasCalled = false;
6  private String query;
7  private List<SObject> records;
8  
9  public String kickoff() {
10    this.validate();
11    return Database.executeBatch(this);
12  }
13
14  public Database.QueryLocator start(Database.BatchableContext bc) {
15    return Database.getQueryLocator(
16      this.query != null ? this.query : FALLBACK_QUERY
17    );
18  }
19
20  public void execute(
21    Database.BatchableContext bc,
22    List localRecords
23  ) {
24    this.hasBeenEnqueuedAsync = false;
25    this.innerExecute(this.records != null ? this.records : localRecords);
26  }
27
28  public virtual void finish(Database.BatchableContext bc) {
29  }
30
31 
32  protected abstract void innerExecute(List<SObject> records);
33
34  private void validate() {
35    if (this.getWasCalled == false) {
36      throw new AsyncException(
37        'Please call "get" to retrieve the correct Process instance' +
38        ' before calling kickoff'
39      );
40    }
41  }
42}

Don’t worry too much about the query and records instance variables, they will come into play soon. The crucial parts to the above are:

  • The AsyncProcessor class is marked as abstract
  • The innerExecute method is also abstract
  • The methods required for Database.Batchable have been defined
  • The kickoff method has been defined, which satisfies the Process interface

By initializing a new subclass of DataProcessor, and then calling the get method, you receive an instance of the DataProcessor.Process interface:

  • Either by providing a String-based query
  • Or by providing a list of records
1public abstract without sharing class AsyncProcessor implements Database.Batchable, Database.RaisesPlatformEvents, Process { 
2  public Process get(String query) {
3    return this.getProcess(query?.toLowerCase(), null);
4  }
5
6  public Process get(List<SObject> records) {
7    return this.getProcess(null, records);
8  }
9
10  protected Process getProcess(String query, List<SObject> records) {
11    this.getWasCalled = true;
12    this.records = records;
13    this.query = query;
14
15    Integer recordCount = query == null
16      ? records.size()
17      : Database.countQuery(
18          query.replace(query.substringBeforeLast(' from '), 'select count() ')
19        );
20    Boolean shouldBatch = recordCount > this.getLimitToBatch();
21    
22    Process process = this;
23    if (shouldBatch == false && this.getCanEnqueue()) {
24      // AsyncProcessorQueueable will be shown next
25      process = new AsyncProcessorQueueable(
26        this
27      );
28    }
29    return process;
30  }
31  
32  protected virtual Integer getLimitToBatch() {
33    return Limits.getLimitQueryRows();
34  }
35  
36  private Boolean getCanEnqueue() {
37    // only one Queueable can be started per async transaction
38    return this.hasBeenEnqueuedAsync == false ||
39      (this.isAsync() == false &&
40      Limits.getQueueableJobs() < Limits.getLimitQueueableJobs());
41  }
42  
43  private Boolean isAsync() {
44    return System.isQueueable() || System.isBatch() || System.isFuture();
45  }
46}

The most important part in the above is this excerpt:

1Integer recordCount = query == null
2   ? records.size()
3    : Database.countQuery(
4        query.replace(query.substringBefore(' from '), 'select count() ')
5      );
6Boolean shouldBatch = recordCount > this.getLimitToBatch();

The shouldBatch Boolean drives out whether or not it’s a batch or a queueable process that ends up starting up!

Finally, the AsyncProcessorQueueable implementation:

1// in AsyncProcessor.cls
2private class AsyncProcessorQueueable implements System.Queueable, Process {
3  private final AsyncProcessor processor;
4
5  public AsyncProcessorQueueable(AsyncProcessor processor) {
6    this.processor = processor;
7    this.processor.hasBeenEnqueuedAsync = true;
8  }
9
10  public String kickoff() {
11    this.processor.validate();
12    if (this.processor.getCanEnqueue() == false) {
13      return this.processor.kickoff();
14    }
15    return System.enqueueJob(this);
16  }
17
18  public void execute(System.QueueableContext qc) {
19    if (this.processor.records == null && this.processor.query != null) {
20      this.processor.records = Database.query(this.processor.query);
21    }
22    this.processor.innerExecute(this.processor.records);
23    this.processor.finish(new QueueableToBatchableContext(qc));
24  }
25}
26
27private class QueueableToBatchableContext implements Database.BatchableContext {
28  private final Id jobId;
29
30  public QueueableToBatchableContext(System.QueueableContext qc) {
31    this.jobId = qc.getJobId();
32  }
33
34  public Id getJobId() {
35    return this.jobId;
36  }
37
38  public Id getChildJobId() {
39    return null;
40  }
41}

The queueable can also implement the System.Finalizer interface, which allows you to consistently handle errors using only a platform event handler for the BatchApexErrorEvent:

1// in AsyncProcessor.cls
2@TestVisible
3private static BatchApexErrorEvent firedErrorEvent;
4
5private class AsyncProcessorQueueable implements System.Queueable, System.Finalizer, Process {
6  public void execute(System.QueueableContext qc) {
7    System.attachFinalizer(this);
8    // plus the logic shown above
9  }
10
11  public void execute(System.FinalizerContext fc) {
12    switch on fc?.getResult() {
13      when UNHANDLED_EXCEPTION {
14        this.fireBatchApexErrorEvent(fc);
15      }
16    }
17  }
18
19  private void fireBatchApexErrorEvent(System.FinalizerContext fc) {
20    String fullLengthJobScope = String.join(this.getRecordsInScope(), ',');
21    Integer jobScopeLengthLimit = 40000;
22    Integer textFieldLengthLimit = 5000;
23    BatchApexErrorEvent errorEvent = new BatchApexErrorEvent(
24      AsyncApexJobId = fc.getAsyncApexJobId(),
25      DoesExceedJobScopeMaxLength = fullLengthJobScope.length() >
26        jobScopeLengthLimit,
27      ExceptionType = fc.getException().getTypeName(),
28      JobScope = this.getSafeSubstring(
29          fullLengthJobScope,
30          jobScopeLengthLimit
31        )
32        .removeEnd(','),
33      Message = this.getSafeSubstring(
34        fc.getException().getMessage(),
35        textFieldLengthLimit
36      ),
37      Phase = 'EXECUTE',
38      StackTrace = this.getSafeSubstring(
39        fc.getException().getStacktraceString(),
40        textFieldLengthLimit
41      )
42    );
43    firedErrorEvent = errorEvent;
44    EventBus.publish(errorEvent);
45  }
46
47  private List getRecordsInScope() {
48    List scope = new List();
49    for (
50      Id recordId : new Map<Id, SObject>(this.processor.records).keySet()
51    ) {
52      scope.add(recordId);
53    }
54    return scope;
55  }
56
57  private String getSafeSubstring(String target, Integer maxLength) {
58    return target.length() > maxLength
59      ? target.substring(0, maxLength)
60      : target;
61  }
62}

In summary, the overall idea is that subclasses will extend the outer AsyncProcessor class, which forces them to define the innerExecute abstract method.

  • They then can call kickoff to start up their process without having to worry about query limits or which async framework is going to be used by the underlying platform.
    • All platform limits, like only being able to start one queueable per async transaction, are automatically handled for you.
1private Boolean getHasAlreadyEnqueued() {
2              return this.isAlreadyAsync ||
3                (System.isQueueable() == false &&
4                System.isBatch() == false &&
5                System.isFuture() == false &&
6                Limits.getQueueableJobs() < Limits.getLimitQueueableJobs());
7            }
    • You no longer have to worry about how many records are retrieved by any given query; the process will be automatically batched for you if you would otherwise be in danger of exceeding the per-transaction query row limit.
1protected Process getProcess(String query, List<SObject> records) {
2  // ....
3  Boolean shouldBatch = recordCount > this.getLimitToBatch();
4  Process process = this;
5  if (shouldBatch == false && this.getHasAlreadyEnqueued() == false) {
6    process = new AsyncProcessorQueueable(
7      this
8    );
9  }
10  return process;
11}
    • Subclasses can opt into implementing things like Database.Stateful and Database.AllowsCallouts when necessary for their own implementations. Since these are marker interfaces, and don’t require a subclass to implement additional methods, it’s better for only the subclasses that absolutely need this functionality to opt into that functionality (instead of always having them be implemented on AsyncProcessor itself).
1public class HttpProcessor extends AsyncProcessor implements Database.AllowsCallouts {
2  protected override void innerExecute(List<SObject> records) {
3    HttpRequest req = new HttpRequest();
4    req.setMethod('POST');
5    req.setEndpoint('callout:Named_Cred_Name');
6    req.setBody(JSON.serialize(records));
7    
8    new Http().send(req);
9  }
10}

Because, by default, subclasses only have to define their own innerExecute implementation, you are freed up from all of the other ceremony that typically comes with creating standalone Batchable and Queueable classes. Logic that’s specific to your implementation, such as keeping track of how many callouts have been performed if you’re doing something like one callout per record, still needs to be put in place and tested.

Here’s a more complicated example showing how to recursively restart the process if you would go over the callout limit:

1public class BulkSafeHttpProcessor extends AsyncProcessor implements Database.AllowsCallouts {
2  protected override void innerExecute(List<SObject> records) {
3    while (records.isEmpty() == false && Limits.getCallouts() < Limits.getLimitCallouts()) {
4      
5      HttpRequest req = new HttpRequest();
6      req.setMethod('POST');
7      req.setEndpoint('callout:Named_Cred_Name');
8      req.setBody(JSON.serialize(records.remove(0));
9        
10      new Http().send(req);
11    }
12    // recursively restart until there's no more records
13    // to process
14    if (records.isEmpty() == false) {
15        this.kickoff();
16    }
17  }
18}

As another marker interface example, here’s what using Database.Stateful looks like:

1public class CounterProcessor extends AsyncProcessor implements Database.Stateful {
2  private Integer counter = 0;
3
4  public override void finish(Database.BatchableContext bc) {
5    System.debug(this.counter);
6  }
7
8  protected override void innerExecute(List<SObject> records) {
9    this.counter += records.size();
10  }
11}

Notice the complete lack of ceremony in both of these examples. Once you have all of the complicated bits in AsyncProcessor, you get to focus purely on logic. This really helps keep your classes small and well-organized.

Unit testing the async processor

Here, we’ll just show one test which proves out a subclass of AsyncProcessor automatically batching when the configured limit for queueing has been exceeded. You’ll be able to access all of the tests by visiting the repository for this project.

1@IsTest
2private class AsyncProcessorTests extends AsyncProcessor {
3  private static Integer batchLimit = Limits.getLimitQueryRows();
4  private static Boolean executeWasFired = false;
5  private static Boolean finishWasFired = false;
6
7  public override void finish(Database.BatchableContext bc) {
8    finishWasFired = true;
9  }
10
11  protected override void innerExecute(List<SObject> records) {
12    executeCallCounter++;
13    executeWasFired = true;
14  }
15
16  protected override Integer getLimitToBatch() {
17    return batchLimit;
18  }
19
20  @IsTest
21  static void allowsBatchLimitToBeAdjusted() {
22    batchLimit = 0;
23    // here we have to actually do DML so that the batch start method
24    // successfully passes data to the batch execute method
25    insert new Account(Name = AsyncProcessorTests.class.getName());
26
27    Test.startTest();
28    new AsyncProcessorTests().get('SELECT Id FROM Account').kickoff();
29    Test.stopTest();
30
31    Assert.areEqual(
32      1,
33      [
34        SELECT COUNT()
35        FROM AsyncApexJob
36        WHERE
37          Status = 'Completed'
38          AND JobType = 'BatchApexWorker'
39          AND ApexClass.Name = :AsyncProcessorTests.class.getName()
40      ]
41    );
42    Assert.isTrue(executeWasFired);
43    Assert.isTrue(finishWasFired);
44  }
45}

Conclusion

The AsyncProcessor pattern lets us focus on implementing our async logic without having to directly specify exactly how the work is performed. More advanced users of this pattern may prefer to override information like the batch size, or allow for things like with/without sharing query contexts. While there are many additional nuances that can be considered, this pattern is a great recipe that can also be used as-is whenever you need to use asynchronous Apex. Check out the full source code to learn more.

About the author

James Simone

James Simone is a Lead Member of Technical Staff at Salesforce, and he has been developing on the Salesforce Platform since 2015. He’s been blogging since late 2019 on the subject of Apex, Flow, LWC, and more in The Joys of Apex. When not writing code, he enjoys rock climbing, sourdough bread baking, and running with his dog. He’s previously blogged about the AsyncProcessor pattern in The Joys of Apex post: Batchable & Queueable Apex.

More Blog Posts

The Salesforce Developer’s Guide to the Summer ’24 Release

The Salesforce Developer’s Guide to the Summer ’24 Release

The Summer ’24 release is here! In this post, we highlight what’s new for developers across the Salesforce ecosystem.May 07, 2024

The Salesforce Developer’s Guide to the Winter ’25 Release

The Salesforce Developer’s Guide to the Winter ’25 Release

The Winter '25 release is here! In this post, we highlight what’s new for developers across the Salesforce ecosystem.September 03, 2024

Make Apex REST APIs Available as Agent Actions

Make Apex REST APIs Available as Agent Actions

Make Apex REST APIs available as agent actions that can be incorporated into your agents, enabling them to call your Apex REST APIs to leverage custom logic.March 27, 2025