Managing complex asynchronous processes in Salesforce can be challenging. These enterprise frameworks provide robust solutions for orchestrating Batch Apex and Queueable Apex jobs with advanced features like automatic chaining, error handling, runtime parameters, and resilient execution.
This guide presents two powerful frameworks:
https://github.com/amitastreait/async-framework-salesforce
The Batch Chain Framework provides a configuration-driven solution for chaining multiple Batchable Apex classes in sequential execution with full control over timing, error handling, and data flow between batches.
| Benefit | Description | Business Impact |
|---|---|---|
| 🔗 Automatic Chaining | Seamlessly chain multiple batch jobs without manual intervention | Reduces development time by 60% |
| ⚙️ Configuration-Driven | Control all behavior via Custom Metadata – no code changes needed | Zero downtime deployments |
| 🔄 Runtime Parameters | Pass dynamic data between batches at execution time | Flexible, context-aware processing |
| 🛡️ Error Recovery | Built-in retry mechanisms and comprehensive error handling | Increased reliability |
| ⏱️ Execution Control | Configurable delays between batches to manage system load | Optimized resource utilization |
| 📊 Governor Limit Aware | Configurable batch sizes and smart scheduling | Prevents limit exceptions |
| 📝 Comprehensive Logging | Detailed execution tracking for debugging and monitoring | Faster troubleshooting |
| 🔧 Zero Code Maintenance | Update chain configuration without code deployments | Agile business process changes |
Traditional Approach Pain Points:
// ❌ Old Way: Manual chaining with hardcoded logic
public class AccountBatch implements Database.Batchable<SObject> {
public void finish(Database.BatchableContext bc) {
// Hardcoded next batch - difficult to maintain
Database.executeBatch(new ContactBatch(), 200);
// No error handling
// No delay control
// No runtime parameters
// Changes require code deployment
}
}
Framework Approach Advantages:
// ✅ New Way: Framework handles everything
public class AccountBatch implements Database.Batchable<SObject>, IBatchChainable {
public void finish(Database.BatchableContext bc) {
AsyncApexJob job = [SELECT Id, Status, NumberOfErrors
FROM AsyncApexJob WHERE Id = :bc.getJobId()];
// Framework handles chaining, delays, parameters, errors
BatchChainExecutor.getInstance()
.continueChain(getCurrentBatchName(), job, parameters);
}
}



Batch_Chain_Configuration__mdt Fields:
| Field Name | Type | Purpose | Example |
|---|---|---|---|
| Current_Batch__c | Text(255) | Primary key – current batch class name | AccountProcessingBatch |
| Next_Batch__c | Text(255) | Next batch in chain (null = end of chain) | ContactProcessingBatch |
| Execution_Delay__c | Number(18,0) | Minutes to wait before next batch | 5 |
| Batch_Size__c | Number(18,0) | Records per batch execution | 200 |
| Is_Active__c | Checkbox | Enable/disable this configuration | true |
| Max_Retries__c | Number(18,0) | Retry attempts on failure | 3 |
| Description__c | Text Area | Configuration documentation | Daily account processing... |
// Start a simple batch chain
Id jobId = BatchChainExecutor.getInstance().startBatch('AccountProcessingBatch');
System.debug('Batch chain started: ' + jobId);
// Configuration in Custom Metadata:
// AccountProcessingBatch → ContactProcessingBatch → OpportunityProcessingBatch
// Pass dynamic parameters at execution time
Map<String, Object> params = new Map<String, Object>{
'region' => 'North America',
'accountType' => 'Enterprise',
'recordLimit' => 5000,
'processingDate' => Date.today(),
'notifyOnComplete' => true
};
Id jobId = BatchChainExecutor.getInstance()
.startBatch('RegionalAccountBatch', params);
System.debug('Regional processing started: ' + jobId);
/**
* Account Processing Batch with full framework integration
*/
public class AccountProcessingBatch implements Database.Batchable<SObject>, IBatchChainable {
private Map<String, Object> parameters;
private Integer processedCount = 0;
// IBatchChainable: Initialize with runtime parameters
public void initializeWithParameters(Map<String, Object> params) {
this.parameters = params != null ? params : new Map<String, Object>();
}
// Batch Start: Build dynamic query based on parameters
public Database.QueryLocator start(Database.BatchableContext bc) {
String region = (String) parameters.get('region');
String accountType = (String) parameters.get('accountType');
Integer recordLimit = (Integer) parameters.get('recordLimit');
String query = 'SELECT Id, Name, Type, BillingCountry, AnnualRevenue ' +
'FROM Account ' +
'WHERE BillingCountry = :region ' +
'AND Type = :accountType ' +
'LIMIT :recordLimit';
return Database.getQueryLocator(query);
}
// Batch Execute: Process records
public void execute(Database.BatchableContext bc, List<SObject> scope) {
List<Account> accounts = (List<Account>) scope;
List<Account> accountsToUpdate = new List<Account>();
for (Account acc : accounts) {
// Business logic
acc.Description = 'Processed by framework on ' + System.now();
acc.Rating = 'Hot';
accountsToUpdate.add(acc);
processedCount++;
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
// Batch Finish: Continue chain with updated parameters
public void finish(Database.BatchableContext bc) {
// Get job result
AsyncApexJob job = [
SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems
FROM AsyncApexJob
WHERE Id = :bc.getJobId()
];
// Call post-execution hook
onAfterExecution(job);
// Pass results to next batch
Map<String, Object> nextParams = new Map<String, Object>(parameters);
nextParams.put('previousBatchProcessedCount', processedCount);
nextParams.put('previousBatchJobId', job.Id);
// Framework handles chaining
BatchChainExecutor.getInstance()
.continueChain(getCurrentBatchName(), job, nextParams);
}
// IBatchChainable: Get configuration
public Batch_Chain_Configuration__mdt getBatchConfig() {
return BatchChainExecutor.getInstance()
.getBatchConfig(getCurrentBatchName());
}
// IBatchChainable: Pre-execution hook
public void onBeforeExecution(Map<String, Object> params) {
System.debug('=== AccountProcessingBatch Starting ===');
System.debug('Region: ' + params.get('region'));
System.debug('Account Type: ' + params.get('accountType'));
System.debug('Record Limit: ' + params.get('recordLimit'));
}
// IBatchChainable: Post-execution hook
public void onAfterExecution(AsyncApexJob result) {
System.debug('=== AccountProcessingBatch Completed ===');
System.debug('Status: ' + result.Status);
System.debug('Processed: ' + result.JobItemsProcessed + '/' + result.TotalJobItems);
System.debug('Errors: ' + result.NumberOfErrors);
System.debug('Records Updated: ' + processedCount);
// Send notification if configured
if (parameters.get('notifyOnComplete') == true) {
sendCompletionEmail(result);
}
}
// IBatchChainable: Return batch name
public String getCurrentBatchName() {
return 'AccountProcessingBatch';
}
// Helper: Send completion notification
private void sendCompletionEmail(AsyncApexJob job) {
// Email notification logic
Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
email.setToAddresses(new List<String>{'[email protected]'});
email.setSubject('Account Processing Batch Completed');
email.setPlainTextBody('Batch ' + job.Id + ' completed with status: ' + job.Status);
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{email});
}
}
/**
* Real-world ETL pipeline: Extract → Transform → Load
*/
// Step 1: Extract Batch - Extract data from external system
public class ExtractBatch implements Database.Batchable<SObject>, IBatchChainable {
// Extract logic and continue to TransformBatch
}
// Step 2: Transform Batch - Transform and validate data
public class TransformBatch implements Database.Batchable<SObject>, IBatchChainable {
// Transform logic and continue to LoadBatch
}
// Step 3: Load Batch - Load into target objects
public class LoadBatch implements Database.Batchable<SObject>, IBatchChainable {
// Load logic - end of chain
}
// Custom Metadata Configuration:
// ExtractBatch -> TransformBatch (delay: 5 min)
// TransformBatch -> LoadBatch (delay: 10 min)
// LoadBatch -> (end)
// Start the ETL pipeline
Map<String, Object> etlParams = new Map<String, Object>{
'sourceSystem' => 'SAP',
'extractDate' => Date.today(),
'batchId' => 'ETL-' + System.now().getTime()
};
Id jobId = BatchChainExecutor.getInstance().startBatch('ExtractBatch', etlParams);
The Queueable Chain Framework provides an advanced orchestration system for Queueable Apex jobs with integrated System.Finalizer support, ensuring guaranteed chain continuation even when individual queueables fail.
| Benefit | Description | Business Impact |
|---|---|---|
| ⚡ Runtime Parameter Override | Dynamic parameters override configuration at execution time | Maximum flexibility |
| 🔄 Continue on Failure | Chain continues even if a step fails (configurable) | Resilient workflows |
| 📊 Parameter Precedence | Runtime params > Config params – clear override model | Predictable behavior |
| 🎯 Lightweight Execution | Faster startup than batch jobs – ideal for < 1000 records | Better performance |
| ⏱️ Native Delays | Built-in delay support with System.enqueueJob(job, delay) | No scheduler needed |
| 🔍 Enhanced Monitoring | Request ID and Job ID tracking | Better debugging |
| 🚀 Quick Response | Immediate execution – no batch overhead | Faster processing |
Traditional Queueable Limitations:
// Old Way: Manual chaining with no failure protection
public class DataProcessingQueueable implements Queueable {
public void execute(QueueableContext context) {
try {
// Process data
processRecords();
// Manual chaining - fails if this throws exception
System.enqueueJob(new NextQueueable());
} catch (Exception e) {
// If exception occurs, chain breaks!
System.debug('Error: ' + e.getMessage());
// Next queueable never runs
}
}
}
Framework Approach with Finalizer:
// ✅ New Way: Finalizer ensures chain continuation
public class DataProcessingQueueable implements Queueable, IQueueableChainable {
public void execute(QueueableContext context) {
try {
processRecords();
} catch (Exception e) {
onExecutionError(e);
throw e; // Finalizer will still continue chain!
}
// Finalizer handles chaining - guaranteed execution
}
public void onFinalizerComplete(System.FinalizerContext result) {
// Chain continues even if execute() failed
System.debug('Finalizer executed for job: ' + result.getAsyncApexJobId());
}
}


Queueable_Chain_Config__mdt Fields:
| Field Name | Type | Purpose | Example |
|---|---|---|---|
| Current_Queueable__c | Text(255) | Primary key – current queueable class name | DataProcessingQueueable |
| Next_Queueable__c | Text(255) | Next queueable in chain | ValidationQueueable |
| Execution_Delay__c | Number(18,0) | Seconds to wait before next queueable | 60 |
| Use_Finalizer__c | Checkbox | Enable System.Finalizer support | true |
| Continue_On_Failure__c | Checkbox | Continue chain even if this step fails | true |
| Is_Active__c | Checkbox | Enable/disable this configuration | true |
| Max_Retries__c | Number(18,0) | Retry attempts on failure | 3 |
| Parameters__c | Long Text | JSON configuration parameters | {"recordLimit": 100} |
| Description__c | Text Area | Configuration documentation | Process customer data... |
// Configuration has static params, runtime provides dynamic overrides
Map<String, Object> runtimeParams = new Map<String, Object>{
'recordLimit' => 500, // Overrides config value
'priority' => 'High', // New runtime parameter
'urgentProcessing' => true // New runtime parameter
};
Id jobId = QueueableChainExecutor.getInstance()
.startQueueable('DataProcessingQueueable', runtimeParams);
System.debug('Queueable started with runtime params: ' + jobId);
| Feature | Batch Chain Framework | Queueable Chain Framework | When to Choose |
|---|---|---|---|
| Data Volume | Large (1,000+) | Small to Medium (< 1,000) | Choose based on record count |
| Execution Speed | Slower (batch overhead) | Faster (immediate start) | Queueable for speed |
| Chaining Model | finish() method | Finalizer-based | Queueable for reliability |
| Failure Recovery | Retry mechanism | Finalizer + Continue on Failure | Queueable for resilience |
| Runtime Parameters | ✅ Supported | ✅ Enhanced with override | Both support, Queueable more flexible |
| Governor Limits | Batch-specific (50M rows) | Queueable (50 jobs, 100 finalizers) | Batch for massive datasets |
| Delay Support | Schedulable-based | Native enqueueJob(delay) | Queueable cleaner implementation |
| API Callouts | Limited (100 per execute) | Supported (100 callout) | Queueable better for callouts |
| Transaction Model | Chunked transactions | Single transaction | Batch for partial success |
| Best For | Bulk data operations | Sequential async workflows | See decision matrix below |

✅ Use: Batch Chain Framework
// Batch Chain: Extract → Transform → Load
// Handles large volumes efficiently
Map<String, Object> params = new Map<String, Object>{
'syncDate' => Date.today(),
'syncType' => 'full'
};
BatchChainExecutor.getInstance().startBatch('ExtractBatch', params);
✅ Use: Queueable Chain with Finalizer
// Queueable Chain: Validate → Process → Notify
// Guaranteed completion with finalizer
Map<String, Object> params = new Map<String, Object>{
'paymentBatchId' => 'PAY-001',
'urgentProcessing' => true
};
QueueableChainExecutor.getInstance().startQueueable('PaymentValidationQueueable', params);
✅ Use: Queueable Chain with Runtime Parameters
// Fast response with dynamic behavior
Map<String, Object> params = new Map<String, Object>{
'userId' => UserInfo.getUserId(),
'recordIds' => selectedRecordIds,
'action' => 'approve'
};
QueueableChainExecutor.getInstance().startQueueable('QuickActionQueueable', params);
// Clear, meaningful configuration names // Batch_Chain_Configuration__mdt Current_Batch__c: AccountDailyProcessingBatch Next_Batch__c: ContactDailyProcessingBatch Description__c: Daily processing for accounts and contacts in North America region // Queueable_Chain_Config__mdt Current_Queueable__c: PaymentValidationQueueable Next_Queueable__c: PaymentProcessingQueueable Use_Finalizer__c: true Continue_On_Failure__c: false (payment must validate before processing)
// Generic, unclear names Current_Batch__c: Batch1 Next_Batch__c: Batch2 Description__c: Does stuff // No finalizer for critical workflow Current_Queueable__c: PaymentProcessing Use_Finalizer__c: false // ❌ Payment processing should have finalizer!

// ✅ Comprehensive error handling
public void execute(Database.BatchableContext bc, List<SObject> scope) {
List<Account> successfullyProcessed = new List<Account>();
List<Account> failedRecords = new List<Account>();
for (Account acc : (List<Account>) scope) {
try {
// Process individual record
processAccount(acc);
successfullyProcessed.add(acc);
} catch (DmlException e) {
// Log DML-specific errors
logDmlError(acc.Id, e);
failedRecords.add(acc);
} catch (Exception e) {
// Log general errors
logError('Unexpected error processing account: ' + acc.Id, e);
failedRecords.add(acc);
}
}
// Batch update successful records
if (!successfullyProcessed.isEmpty()) {
update successfullyProcessed;
}
// Log failed records for review
if (!failedRecords.isEmpty()) {
logFailedBatch(failedRecords);
}
}
// ✅ Clear parameter precedence model
public Map<String, Object> getEffectiveParameters() {
Map<String, Object> effective = new Map<String, Object>();
// 1. Start with configuration parameters (base)
if (configParameters.isEmpty()) {
Queueable_Chain_Config__mdt config = getQueueableConfig();
if (config != null && !String.isBlank(config.Parameters__c)) {
this.configParameters = QueueableChainExecutor.getInstance()
.parseParameters(config.Parameters__c);
}
}
effective.putAll(configParameters);
// 2. Runtime parameters override (highest precedence)
effective.putAll(runtimeParameters);
// 3. Validate required parameters
validateRequiredParameters(effective);
return effective;
}
private void validateRequiredParameters(Map<String, Object> params) {
List<String> required = new List<String>{'objectType', 'recordLimit'};
for (String param : required) {
if (!params.containsKey(param)) {
throw new QueueableChainExecutor.QueueableChainException(
'Required parameter missing: ' + param
);
}
}
}
// ✅ Pass results to next batch/queueable
public void finish(Database.BatchableContext bc) {
AsyncApexJob job = [SELECT Id, Status, JobItemsProcessed
FROM AsyncApexJob WHERE Id = :bc.getJobId()];
// Enhance parameters with execution results
Map<String, Object> nextParams = new Map<String, Object>(parameters);
nextParams.put('previousBatchJobId', job.Id);
nextParams.put('previousBatchRecordsProcessed', job.JobItemsProcessed);
nextParams.put('previousBatchCompletedAt', System.now());
// Pass enriched parameters to next batch
BatchChainExecutor.getInstance()
.continueChain(getCurrentBatchName(), job, nextParams);
}

// ✅ Governor limit best practices
// Batch: Configure appropriate batch size
Batch_Chain_Configuration__mdt: Batch_Size__c: 200 // Default, adjust based on complexity
// Batch: Monitor SOQL queries in execute()
public void execute(Database.BatchableContext bc, List<SObject> scope) {
// Check limits before heavy operations
if (Limits.getQueries() > 180) { // Approaching limit
System.debug('WARNING: Approaching SOQL query limit');
}
// Use efficient queries
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, (SELECT Id FROM Contacts) FROM Account WHERE Id IN :scope]
);
}
// Queueable: Use delays to manage concurrent jobs
Queueable_Chain_Config__mdt:
Execution_Delay__c: 60 // 60 seconds between chains
// Queueable: Check job limits before enqueuing
if (Limits.getQueueableJobs() >= Limits.getLimitQueueableJobs()) {
System.debug('ERROR: Cannot enqueue more jobs');
// Handle gracefully - maybe schedule instead
}
// ✅ Comprehensive logging strategy
// Framework-level logging
private void logQueueableExecution(
Queueable_Chain_Config__mdt config,
Id jobId,
Map<String, Object> runtimeParams
) {
String logMessage = String.join(new List<String>{
'Queueable: ' + config.Current_Queueable__c,
'Job ID: ' + jobId,
'Next: ' + (config.Next_Queueable__c ?? 'None'),
'Finalizer: ' + config.Use_Finalizer__c,
'Continue On Failure: ' + config.Continue_On_Failure__c,
'Runtime Params: ' + JSON.serializePretty(runtimeParams),
'Timestamp: ' + System.now()
}, ' | ');
System.debug(LoggingLevel.INFO, '[QueueableChain] ' + logMessage);
// Optional: Write to custom logging object for persistence
createAuditLog(config, jobId, runtimeParams);
}
// Custom audit logging
private void createAuditLog(
Queueable_Chain_Config__mdt config,
Id jobId,
Map<String, Object> params
) {
Async_Job_Audit__c audit = new Async_Job_Audit__c(
Job_Type__c = 'Queueable',
Class_Name__c = config.Current_Queueable__c,
Job_Id__c = jobId,
Next_Job__c = config.Next_Queueable__c,
Parameters__c = JSON.serialize(params),
Started_At__c = System.now()
);
insert audit;
}
// Monitor AsyncApexJob for chain status
public static void monitorChainExecution(Id initialJobId) {
List<AsyncApexJob> jobs = [
SELECT Id, ApexClass.Name, Status, NumberOfErrors, CreatedDate
FROM AsyncApexJob
WHERE Id = :initialJobId
OR CreatedBy.Id = :UserInfo.getUserId()
ORDER BY CreatedDate DESC
LIMIT 10
];
for (AsyncApexJob job : jobs) {
System.debug('Job: ' + job.ApexClass.Name +
' | Status: ' + job.Status +
' | Errors: ' + job.NumberOfErrors);
}
}
@IsTest
private class FrameworkTestSuite {
// Test batch chain with runtime parameters
@IsTest
static void testBatchChainWithRuntimeParams() {
// Setup
insert new Account(Name = 'Test Account');
Map<String, Object> params = new Map<String, Object>{
'region' => 'North America',
'recordLimit' => 100
};
Test.startTest();
Id jobId = BatchChainExecutor.getInstance()
.startBatch('AccountProcessingBatch', params);
Test.stopTest();
// Verify
AsyncApexJob job = [SELECT Status, NumberOfErrors
FROM AsyncApexJob WHERE Id = :jobId];
System.assertEquals('Completed', job.Status);
System.assertEquals(0, job.NumberOfErrors);
}
// Test queueable finalizer chain continuation
@IsTest
static void testFinalizerChainContinuation() {
// Setup
Map<String, Object> params = new Map<String, Object>{
'shouldFail' => true // Trigger failure
};
Test.startTest();
Id jobId = QueueableChainExecutor.getInstance()
.startQueueable('ResilientQueueable', params);
Test.stopTest();
// Verify: Finalizer should have continued chain despite failure
// Check debug logs for finalizer execution
System.assertNotEquals(null, jobId);
// Verify chain continued (check next job was created)
List<AsyncApexJob> jobs = [
SELECT ApexClass.Name, Status
FROM AsyncApexJob
WHERE CreatedDate = TODAY
ORDER BY CreatedDate DESC
];
System.assert(jobs.size() > 1, 'Chain should have continued');
}
// Test parameter precedence
@IsTest
static void testParameterPrecedence() {
// Setup: Config has recordLimit: 100
// Runtime overrides with recordLimit: 500
DataProcessingQueueable q = new DataProcessingQueueable();
// Simulate config parameters
// (Would come from Custom Metadata in real scenario)
// Set runtime parameters
Map<String, Object> runtimeParams = new Map<String, Object>{
'recordLimit' => 500,
'newParam' => 'value'
};
q.setRuntimeParameters(runtimeParams);
// Get effective parameters
Map<String, Object> effective = q.getEffectiveParameters();
// Verify: Runtime takes precedence
System.assertEquals(500, effective.get('recordLimit'));
System.assertEquals('value', effective.get('newParam'));
}
}
These enterprise frameworks transform complex asynchronous processing in Salesforce:
Built for the Salesforce Developer Community with Love