Asynchronous Apex · Lesson 30 of 41
Queueable Apex: Chaining, Finalizers and Monitoring
Queueable Apex is the workhorse of background processing. It runs later in its own transaction like @future, but it's a real class: you can pass it any data, get a job Id to monitor, chain one job after another, and attach a Finalizer that runs even if the job fails. Most new async code in Salesforce should be a Queueable.
In this lesson
- Implementing and enqueueing a Queueable
- Monitoring with AsyncApexJob
- Chaining jobs
- Finalizers for failure handling
- Callouts and testing
A Queueable class
// CaseEscalationJob.cls
public with sharing class CaseEscalationJob implements Queueable {
private final List<Case> cases; // any type: sObjects, wrappers, maps…
private final String reason;
public CaseEscalationJob(List<Case> cases, String reason) {
this.cases = cases;
this.reason = reason;
}
public void execute(QueueableContext context) {
List<Case> updates = new List<Case>();
for (Case c : [SELECT Id, Priority FROM Case WHERE Id IN :cases AND IsClosed = false]) {
updates.add(new Case(Id = c.Id, Priority = 'High', Description = 'Escalated: ' + reason));
}
update updates;
}
}
// enqueue it – from a trigger handler, a controller, anywhere
Id jobId = System.enqueueJob(new CaseEscalationJob(cases, 'SLA breached'));
// check on it later
AsyncApexJob job = [SELECT Status, NumberOfErrors, ExtendedStatus FROM AsyncApexJob WHERE Id = :jobId];
enqueueJob returns the job's Id straight away. AsyncApexJob tells you its status: Queued, Preparing, Processing, Completed, Failed, Aborted. Setup → Apex Jobs shows the same list.
Testing it
// CaseEscalationJobTest.cls
@IsTest
private class CaseEscalationJobTest {
@IsTest
static void escalatesOnlyOpenCases() {
List<Case> cases = new List<Case>{
new Case(Subject = 'Open one', Priority = 'Low', Status = 'New'),
new Case(Subject = 'Closed one', Priority = 'Low', Status = 'Closed')
};
insert cases;
Test.startTest();
Id jobId = System.enqueueJob(new CaseEscalationJob(cases, 'SLA breached'));
Test.stopTest(); // the job runs here
Assert.areEqual('Completed', [SELECT Status FROM AsyncApexJob WHERE Id = :jobId].Status);
Assert.areEqual('High', [SELECT Priority FROM Case WHERE Subject = 'Open one'].Priority);
Assert.areEqual('Low', [SELECT Priority FROM Case WHERE Subject = 'Closed one'].Priority);
}
}
✓ CaseEscalationJobTest.escalatesOnlyOpenCases Tests: 1 passed, 0 failed, 1 total
Chaining jobs
A Queueable can enqueue one more Queueable from its execute method. That's how you process work in steps, each with fresh limits – page through records, or call an API page by page:
// ContactCleanupJob.cls
public with sharing class ContactCleanupJob implements Queueable {
private final Integer batchSize;
private final Integer round;
public ContactCleanupJob(Integer batchSize, Integer round) {
this.batchSize = batchSize;
this.round = round;
}
public void execute(QueueableContext context) {
List<Contact> dirty = [SELECT Id, Email FROM Contact WHERE Department = 'needs cleanup' LIMIT :batchSize];
for (Contact c : dirty) {
c.Email = c.Email?.trim().toLowerCase();
c.Department = 'cleaned in round ' + round;
}
update dirty;
Boolean moreWork = [SELECT COUNT() FROM Contact WHERE Department = 'needs cleanup'] > 0;
if (moreWork && !Test.isRunningTest()) { // tests can't run a chain
System.enqueueJob(new ContactCleanupJob(batchSize, round + 1));
}
}
}
- Only one child job can be enqueued from an executing Queueable.
- Always have a stop condition – an endless chain keeps consuming your org's daily async executions.
- In tests, a chained job can't run after
stopTest(); guard the enqueue withTest.isRunningTest()(as here) or set a maximum stack depth withAsyncOptions, and test each step directly.
Finalizers: handle failures
If a Queueable throws an uncaught exception, its work rolls back and nothing happens unless someone checks Apex Jobs. A Finalizer runs after the job finishes – successfully or not – in its own transaction, so it can log the failure or retry:
// ImportJob.cls
public with sharing class ImportJob implements Queueable {
private final List<String> names;
public ImportJob(List<String> names) { this.names = names; }
public void execute(QueueableContext context) {
System.attachFinalizer(new ImportFinalizer(names.size()));
List<Account> accounts = new List<Account>();
for (String n : names) accounts.add(new Account(Name = n));
insert accounts; // fails if a name is blank
}
}
// ImportFinalizer.cls
public with sharing class ImportFinalizer implements Finalizer {
private final Integer expected;
public ImportFinalizer(Integer expected) { this.expected = expected; }
public void execute(FinalizerContext ctx) {
if (ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
// the job's own DML was rolled back; record what happened in a new transaction
insert new Task(Subject = 'Import failed: ' + ctx.getException().getTypeName(),
Description = ctx.getException().getMessage().left(200));
}
}
}
// ImportJobTest.cls
@IsTest
private class ImportJobTest {
@IsTest
static void successfulImport() {
Test.startTest();
System.enqueueJob(new ImportJob(new List<String>{ 'Acme', 'Globex' }));
Test.stopTest();
Assert.areEqual(2, [SELECT COUNT() FROM Account]);
Assert.areEqual(0, [SELECT COUNT() FROM Task]);
}
@IsTest
static void failureIsRecordedByTheFinalizer() {
try {
Test.startTest();
System.enqueueJob(new ImportJob(new List<String>{ 'Acme', '' }));
Test.stopTest();
} catch (DmlException e) {
// in tests the job's exception surfaces at stopTest; in production it doesn't
}
Assert.areEqual(0, [SELECT COUNT() FROM Account], 'the job rolled back');
Task t = [SELECT Subject FROM Task LIMIT 1];
Assert.areEqual('Import failed: System.DmlException', t.Subject);
}
}
✓ ImportJobTest.failureIsRecordedByTheFinalizer ✓ ImportJobTest.successfulImport Tests: 2 passed, 0 failed, 2 total
Callouts and other options
- Implement
Database.AllowsCalloutsas well to make HTTP callouts:public class SyncJob implements Queueable, Database.AllowsCallouts. System.enqueueJob(job, delayInMinutes)delays the start (0–10 minutes) – handy for retries with back-off.AsyncOptionslets you set a duplicate signature (to avoid enqueueing the same job twice) and a maximum stack depth for chains.- Limit: 50 jobs can be enqueued per synchronous transaction; one per executing Queueable.
Practice
Try it yourself
Convert the
ErpSyncfuture method from the previous lesson into a Queueable that also retries once after 5 minutes if the API returns 503.Show answer
public class ErpSyncJob implements Queueable, Database.AllowsCallouts { Set<Id> ids; Integer attempt; … execute: send the request; if (res.getStatusCode() == 503 && attempt < 2) System.enqueueJob(new ErpSyncJob(ids, attempt + 1), 5); }Your Queueable fails silently in production now and then. What do you add?
Show answer
A Finalizer that checks
ctx.getResult()and logs the exception (or re-enqueues the job) – plus monitoring of AsyncApexJob for Failed jobs.
Quick check
What does System.enqueueJob return?
The job Id (an AsyncApexJob Id).
How many jobs can an executing Queueable enqueue?
One.
When does a Finalizer run?
After its Queueable finishes – whether it succeeded or threw an exception.
Summary
- Queueables accept any state, return a job Id and can be monitored.
- Chain one job at a time, with a clear stop condition.
- Attach a Finalizer to log or retry failures.
- Add Database.AllowsCallouts for callouts; test with startTest/stopTest.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.