Skip to content

Async Processing - Guide

Framework: KernDX Package Type: Managed Package

Target Audience:

  • Developers - Building schedulable jobs, batch processing, and asynchronous operations with automatic strategy selection
  • Architects - Designing scalable async processing patterns with governor limit awareness and adaptive execution
  • Business Analysts - Understanding scheduled job configuration, execution strategies, and monitoring capabilities

What problem does this solve?

Some background work is too big or too slow to run while a user waits: processing tens of thousands of records, calling external systems, or running on a nightly schedule. Salesforce gives you three separate tools for this, and choosing the right one (and switching between them as data volumes change) is a recurring source of bugs and governor-limit failures.

This framework lets you write your processing logic once and have it run at the right scale automatically. It picks Queueable, Batch, or parallel execution for you, based on how many records there are and how close you are to platform limits.

Read it if you build background jobs, design for governor-limit safety, or configure and monitor scheduled jobs. Use it for asynchronous record processing, multi-step workflows, and recurring scheduled jobs.


Mental model

Think of the framework as a freight dispatcher at a depot. You hand it the cargo (your records) and the job to do; it looks at how much there is and how much capacity is free right now, then chooses whether to send a few fast vans (parallel Queueables), a relay of vans passing the load along (chained Queueables), or one big lorry (Batch Apex). You describe the work once; the dispatcher decides how to move it.


Use this when

  • The work is too large or slow to finish while a user waits, and you don't want to hand-pick Queueable versus Batch as data volumes change.
  • You need a job to run on a recurring schedule, and you want admins to create or change those schedules as configuration records rather than through a code deployment.
  • You have a multi-step workflow where each phase needs its own fresh set of governor limits (load, then transform, then notify).
  • You want every async run traceable: the same tracking ID across triggers, queries, callouts, and jobs, plus a searchable record of what happened.
  • Several developers work on background jobs and you want them all following one pattern instead of inventing their own.

Don't use this when

  • The operation is small and synchronous (under 100 records) and finishes well within governor limits. Plain Apex is simpler.
  • A declarative Scheduled Flow already handles the recurring job without code. Use it; it is less to maintain.
  • You need a one-off batch or queueable you will never reuse, or you want full custom control over start / execute / finish. The platform's own Batch and Queueable patterns are a better fit, and the framework never blocks you from using them directly (see How to opt out).

Table of Contents

Expand
  1. What problem does this solve?
  2. Mental model
  3. Use this when
  4. Don't use this when
  5. Quick Navigation
  6. Quick Start
  7. Why choose this over the built-in option?
  8. What are the moving parts?
  9. How to opt out
  10. KernDX vs OOTB: Async Framework Comparison
  11. How does it work?
  12. Architecture Decision Guide
  13. How do I write the processing logic?
  14. Async Chain Orchestration
  15. Scheduler Framework
  16. Transaction Correlation in Async Operations
  17. Capability Matrix (for Analysts)
  18. Monitoring and Troubleshooting
  19. Testing
  20. Common Pitfalls
  21. Anti-Patterns
  22. Best Practices
  23. Related Documentation
  24. Summary

Quick Navigation

I am a...I need to...Go to...
ArchitectDecide when to use async patternsArchitecture Decision Guide
ArchitectCompare with OOTB Batch/QueueableKernDX vs OOTB
DeveloperProcess records asynchronouslyQuick Start
DeveloperCreate a scheduled jobScheduler Framework
DeveloperImplement custom processingProcessing logic
DeveloperBuild multi-step async workflowsAsync Chain Orchestration
AnalystKnow what's availableCapability Matrix
AnalystConfigure scheduled jobsScheduledJob Configuration

Quick Start

To process records in the background, you write one class that holds your logic and hand it to the launcher. Put your logic in a class that implements IF_Async.Processable, then launch it with UTIL_AsynchronousJobLauncher. The framework picks the right execution strategy for you.

Step-by-step walkthrough: Fast Start - Async Processing covers implementation, testing, and common pitfalls.

apex
public with sharing class MyProcessor implements IF_Async.Processable
{
	public void execute(List<Object> items)
	{
		List<Account> accounts = (List<Account>)items;
		for(Account account : accounts)
		{
			account.Description = 'Processed: ' + DateTime.now();
		}
		DML_Builder.newTransaction().doUpdate(accounts).execute();
	}
}

// Launch
Id jobId = UTIL_AsynchronousJobLauncher.process(records, new MyProcessor());

For deeper coverage, continue reading the sections below.


Why choose this over the built-in option?

The three Salesforce tools this framework sits on top of are Queueable, Batch Apex, and Scheduled Apex. The native tools are entirely usable on their own. ("Governor limits" are the platform's per-transaction caps on how much work Apex can do at once.) What the framework adds is this:

  • It picks Queueable or Batch for you, by volume. You don't guess the right tool upfront or rewrite the call when data grows: the same code keeps working as volumes change.
  • One entry point launches complex async work. A single method call replaces hand-wiring each native mechanism.
  • Multi-step workflows share data and survive limits. Chains run phases in order, pass data between steps, carry built-in error and completion handlers, and keep a durable record of progress.
  • An existing outbound integration becomes a chain step unchanged. ApiStep wraps any API_Outbound handler with no edits to that handler.
  • You can watch a run happen. The Chain Monitor UI shows live updates, a step-by-step timeline, and error detail panels, with a View logs button that jumps to the run's correlated logs in the Log Console.
  • Admins change a job's parameters without touching code. Configurable schedulers read their settings from a record.
  • Errors and logs look the same everywhere. One consistent pattern across every async strategy, instead of one per mechanism.
  • It stays inside the platform's per-transaction caps automatically. Governor-limit awareness is built in, so you don't track those limits by hand.
  • Tests can control async behaviour. Built-in test support lets you drive async work from @IsTest code.

For the cases where the native tools are the better choice (a one-off job, full control over start / execute / finish, or avoiding even a thin layer), see the full comparison in KernDX vs OOTB and How to opt out.


What are the moving parts?

The framework is made up of six pieces that work together:

  1. UTIL_AsynchronousJobLauncher is the entry point: one place to launch async jobs, with the right strategy chosen for you.
  2. IF_Async.Processable is the interface where you put your processing logic.
  3. UTIL_AdaptiveAsynchronousProcessor is the engine that picks Queueable, Batch, or synchronous execution based on data volume. The framework drives it for you.
  4. UTIL_AsyncChain runs multi-step workflows that share data, track progress, and have error and completion handlers, including a built-in ApiStep web service bridge.
  5. IF_Schedulable is the interface for configurable scheduled jobs that accept parameters, managed through ScheduledJob__c records.
  6. CTRL_ChainMonitor powers the real-time Chain Monitor UI (4 LWC components: chainMonitor, chainMonitorList, chainMonitorDetail, chainStepTimeline), with live updates, a step timeline, and error detail panels.

Responsibilities: The Async framework launches and manages long-running or deferred work. It does not contain the business logic itself: that belongs in your Processable implementation. It does not query data either; you pass records or a Builder to the launcher.

Async Framework Scope: Adaptive strategy selection (Queueable vs Batch), chain orchestration with shared context and ApiStep web service bridge, declarative scheduling via ScheduledJob__c, real-time Chain Monitor UI, and four pre-built schedulable reference implementations (SCHED_DeactivateUsers, SCHED_PerformBatchedCallouts, SCHED_ProcessLoginHistory, SCHED_PurgeRecords) extending the abstract SCHED_Base.

Declarative scheduling: Configure recurring jobs entirely through ScheduledJob__c records. Set a class name and a cron expression, then activate. No code deployment is needed to schedule, reschedule, or deactivate jobs.


How to opt out

Sometimes you want to step outside the framework and use Salesforce's own async tools directly. You can, at any time: the framework never takes that control away from you.

The framework offers three execution strategies and switches between them automatically. UTIL_AsyncChain is just one of your options, meant for sequenced, dependent work that shares state from step to step. Running jobs in parallel is a separate, equally supported strategy. The table below maps each thing you might want to do to the direct route that gives it to you.

You needUseSee
Parallel Queueables (multiple jobs running concurrently)UTIL_AsynchronousJobLauncher.process() auto-selects PARALLEL_QUEUEABLES when the 10-concurrent platform limit allows.Execution Strategy Comparison
Direct System.enqueueJob() for an independent QueueableWorks unchanged. Nothing intercepts enqueueJob. You freely write implements Queueable and enqueue independently of any chain.Queueable Pattern with Correlation
Native Database.Batchable / Database.StatefulWrite your own batch class as you always would. There is no mandatory base class. The framework's IF_Async.Processable interface is optional.When to Use OOTB Batch/Queueable
Change Data Capture / Platform Event triggersYou write trigger MyTrigger on Account__ChangeEvent directly. Framework code calls EventBus.publishWithAccessLevel() without wrapping your triggers.
Force a specific execution strategy instead of letting the dispatcher chooseIF_Async.AsynchronousExecutionStrategy enum exposes PARALLEL_QUEUEABLES, CHAINABLE, BATCH; pass the one you want to the launcher.Execution Strategy Comparison
10-concurrent-limit awareness for adaptive degradationThe adaptive processor reads UTIL_Limits.queueableJobs().remaining() and steps down PARALLEL → CHAINABLE → BATCH automatically as slots run low.Execution Strategy Flow

A reminder on scope: UTIL_AsyncChain is for one specific pattern, sequenced steps that depend on each other and share context across transactions. It is the wrong tool for parallel work, but that does not mean parallel work is unavailable. The framework names the right tool for that case (PARALLEL_QUEUEABLES) explicitly.


KernDX vs OOTB: Async Framework Comparison

Salesforce Out-of-the-Box Alternative

Salesforce provides Batch Apex, Queueable Apex, and Scheduled Apex as standard async mechanisms:

apex
// OOTB Batch Apex
public class MyBatch implements Database.Batchable<SObject> {
    public Database.QueryLocator start(Database.BatchableContext ctx) {
        return Database.getQueryLocator('SELECT Id FROM Account');
    }
    public void execute(Database.BatchableContext ctx, List<Account> scope) {
        // Process accounts
    }
    public void finish(Database.BatchableContext ctx) { }
}

// OOTB Queueable Apex
public class MyQueueable implements Queueable {
    public void execute(QueueableContext ctx) {
        // Process data
    }
}

Pros & Cons Comparison

The framework adds convenience and consistency; the native tools win on simplicity and raw control. The full feature-by-feature breakdown is below.

Full feature comparison
FeatureKernDX Async FrameworkSalesforce OOTB Batch/Queueable
Auto Strategy SelectionAUTO mode chooses optimal approachDeveloper must decide upfront
Unified APIOne interface for Batch & QueueableDifferent interfaces (Batchable vs Queueable)
Declarative SchedulingScheduledJob__c custom objectRequires code deployment
Configurable JobsIF_Schedulable with parameter definitionsMust hard-code or use custom settings
Parallel QueueablesPARALLEL_QUEUEABLES strategyManual enqueue loop required
Chained QueueablesCHAINABLE with auto-chainingManual chaining in execute()
Error EventsQueueable crashes captured by a finalizer → durable LogEntry__cNative Batch fires BatchApexErrorEvent; Queueables surface nothing
FinalisationIF_Async.Finishable works for bothOnly Batch has finish(), Queueable needs Finalizer
Log CorrelationLOG_Builder serializeContext()/hydrateContext()Must implement manually
Query-Based ProcessingPass QRY_Builder.BuilderDatabase.QueryLocator in Batch
Callout SupportDatabase.AllowsCallouts includedMust add interface manually
Test SupportAsyncOptions for controlled testingStandard Test.startTest()/stopTest()
SimplicityRequires learning frameworkStandard Salesforce patterns
Learning CurveFramework-specific knowledgeStandard Apex knowledge
PerformanceA thin layer over the platform's own executionDirect platform execution
FlexibilityMust use framework patternsFull control over implementation

When to Use KernDX Async Framework

  • Variable data volumes. The framework auto-selects Queueable vs Batch, so the same code keeps working as data grows.
  • Reusable processing logic. One processor works across every strategy.
  • Declarative job management. Admins manage schedules through ScheduledJob__c records, no code deployment.
  • Log correlation required. Trace async operations back to where they started via LOG_Builder.
  • Parallel queueable patterns. The framework handles chunking and enqueuing for you.
  • Consistent error handling. You get the same standardised error events everywhere.
  • Multiple developers. Everyone follows the same pattern instead of inventing their own.
  • Long-running applications that need full lifecycle management (launch, monitor, finalise, and reschedule in one place).

When to Use OOTB Batch/Queueable

  • Simple one-off jobs. A basic batch or queueable you won't reuse.
  • Maximum control. You need custom start, execute, or finish behaviour.
  • Performance critical. You want to avoid even a thin framework layer.
  • Stateful batch. You need Database.Stateful to keep state across batches.
  • Custom chaining logic. Your chaining has involved conditional rules.
  • Few async jobs. A small codebase where shared conventions add little.
  • Team preference. Your developers prefer working with the platform's patterns directly.

Example Comparison

OOTB Batch Apex (Verbose, Separate Classes):

apex
// AccountProcessor.cls - Batch implementation
public class AccountProcessor implements Database.Batchable<SObject>
{
	public Database.QueryLocator start(Database.BatchableContext context)
	{
		return Database.getQueryLocator('SELECT Id, Name FROM Account WHERE Industry = \'Technology\'');
	}

	public void execute(Database.BatchableContext context, List<Account> scope)
	{
		for(Account account : scope)
		{
			account.Description = 'Processed: ' + DateTime.now();
		}
		update scope;
	}

	public void finish(Database.BatchableContext context)
	{
		// Send notification email
		Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
		email.setSubject('Batch Complete');
		email.setToAddresses(new List<String>{'admin@company.com'});
		email.setPlainTextBody('Job finished: ' + context.getJobId());
		Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{email});
	}
}

// AccountQueueable.cls - Separate Queueable for small datasets
public class AccountQueueable implements Queueable
{
	private List<Account> accounts;

	public AccountQueueable(List<Account> accounts)
	{
		this.accounts = accounts;
	}

	public void execute(QueueableContext context)
	{
		for(Account account : accounts)
		{
			account.Description = 'Processed: ' + DateTime.now();
		}
		update accounts;
	}
}

// Caller must decide which to use:
if(accounts.size() > 200)
{
	Database.executeBatch(new AccountProcessor(), 200);
}
else
{
	System.enqueueJob(new AccountQueueable(accounts));
}

KernDX Async Framework (Unified, Reusable):

apex
// AccountProcessor.cls - One class works for both Batch and Queueable
public with sharing class AccountProcessor implements
	IF_Async.Processable,
	IF_Async.Finishable
{
	public void execute(List<Object> items)
	{
		List<Account> accounts = (List<Account>)items;
		for(Account account : accounts)
		{
			account.Description = 'Processed: ' + DateTime.now();
		}
		DML_Builder.newTransaction().doUpdate(accounts).execute();
	}

	public void finish(Database.BatchableContext context)
	{
		Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
		email.setSubject('Processing Complete');
		email.setToAddresses(new List<String>{'admin@company.com'});
		email.setPlainTextBody('Job finished: ' + context.getJobId());
		Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{email});
	}
}

// Framework auto-selects strategy - no decision needed:
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
	.fields(new List<SObjectField>{Account.Name, Account.Description})
	.condition(Account.Industry).equals('Technology')
	.toList();
Id jobId = UTIL_AsynchronousJobLauncher.process(accounts, new AccountProcessor());
// Small dataset -> PARALLEL_QUEUEABLES (fastest)
// Medium dataset -> CHAINABLE queueables
// Large dataset -> BATCH Apex

Key Differences:

  • OOTB: Two separate classes, manual strategy selection, duplicate logic
  • KernDX: One processor class, automatic strategy selection, reusable logic

How does it work?

System Architecture Diagram

text
+----------------------------------------------------------------------------------+
|                         ASYNCHRONOUS OPERATIONS FRAMEWORK                        |
+----------------------------------------------------------------------------------+
|                                                                                  |
|  +------------------------------------------------------------------------+      |
|  |                        ENTRY POINTS (Choose One)                       |      |
|  +------------------------------------------------------------------------+      |
|  |                                                                        |      |
|  |   +------------------+    +------------------+    +-----------------+  |      |
|  |   |  UTIL_Async-     |    |  ScheduledJob__c |    |   System.       |  |      |
|  |   |  JobLauncher     |    |  (Declarative)   |    |   schedule()    |  |      |
|  |   |                  |    |                  |    |   (Programmatic)|  |      |
|  |   |  * Ad-hoc jobs   |    |  * Recurring     |    |  * One-off      |  |      |
|  |   |  * Trigger-based |    |  * UI-managed    |    |  * Script-based |  |      |
|  |   |  * API-initiated |    |  * No deployment |    |  * Full control |  |      |
|  |   +--------+---------+    +--------+---------+    +--------+--------+  |      |
|  |            |                       |                       |           |      |
|  +------------+-----------------------+-----------------------+-----------+      |
|               |                       |                       |                  |
|               v                       v                       v                  |
|  +------------------------------------------------------------------------+      |
|  |                         PROCESSING ENGINE                              |      |
|  +------------------------------------------------------------------------+      |
|  |                                                                        |      |
|  |   +----------------------------------------------------------------+  |      |
|  |   |            UTIL_AdaptiveAsynchronousProcessor                  |  |      |
|  |   |                                                                |  |      |
|  |   |   +---------------------------------------------------------+ |  |      |
|  |   |   |              STRATEGY SELECTION (AUTO mode)              | |  |      |
|  |   |   |                                                         | |  |      |
|  |   |   |   Items <= 50 ----------> PARALLEL_QUEUEABLES           | |  |      |
|  |   |   |   (and slots available)   (Fastest - concurrent)        | |  |      |
|  |   |   |                                                         | |  |      |
|  |   |   |   Items <= threshold ---> CHAINABLE                     | |  |      |
|  |   |   |   (limited slots)         (Sequential queueables)       | |  |      |
|  |   |   |                                                         | |  |      |
|  |   |   |   Items > threshold ----> BATCH                        | |  |      |
|  |   |   |   (or query-based)        (Batch Apex)                  | |  |      |
|  |   |   +---------------------------------------------------------+ |  |      |
|  |   +----------------------------------------------------------------+  |      |
|  |                                                                        |      |
|  +------------------------------------------------------------------------+      |
|                                                                                  |
|  +------------------------------------------------------------------------+      |
|  |                         YOUR BUSINESS LOGIC                            |      |
|  +------------------------------------------------------------------------+      |
|  |                                                                        |      |
|  |   +----------------------+         +----------------------+            |      |
|  |   |   Processable    |         |   Finishable     |            |      |
|  |   |                      |         |   (Optional)         |            |      |
|  |   |   execute(items)     |         |   finish(context)    |            |      |
|  |   |                      |         |                      |            |      |
|  |   |   Your processing    |         |   Cleanup, notify,   |            |      |
|  |   |   logic goes here    |         |   chain next job     |            |      |
|  |   +----------------------+         +----------------------+            |      |
|  |                                                                        |      |
|  +------------------------------------------------------------------------+      |
|                                                                                  |
+----------------------------------------------------------------------------------+

Execution Strategy Flow

text
                              +-------------------+
                              |   START JOB       |
                              |   .process()      |
                              +--------+----------+
                                       |
                                       v
                         +-------------------------+
                         |  Strategy = AUTO?       |
                         +------------+------------+
                                      |
                    +-----------------+------------------+
                    | YES             |                  | NO
                    v                 |                  v
        +-------------------+        |      +-------------------+
        | Query-based?      |        |      | Use specified     |
        +---------+---------+        |      | strategy directly |
                  |                  |      +-------------------+
         +--------+--------+        |
         | YES             | NO     |
         v                 v        |
   +----------+    +---------------+|
   |Can query?|    | Count items   ||
   |(limits)  |    |               ||
   +----+-----+    +-------+------+ |
        |                  |        |
   NO   | YES              |        |
   -----+                  |        |
        |                  v        |
        |     +--------------------+|
        |     |Items > threshold?  ||
        |     |(default: 50,000)   ||
        |     +---------+----------+|
        |               |           |
        |    +----------+----------+|
        |    | YES                 || NO
        v    v                     v|
   +-----------+         +-------------------+
   |   BATCH   |         | Queueable slots   |
   |   APEX    |         | available?        |
   +-----------+         +---------+---------+
                                   |
                        +----------+----------+
                        | YES                 | NO
                        v                     v
               +-----------------+    +-------------+
               | Enough for all? |    |   BATCH     |
               +--------+--------+    |   APEX      |
                        |             +-------------+
             +----------+----------+
             | YES                 | NO
             v                     v
    +-----------------+   +-----------------+
    |    PARALLEL     |   |   CHAINABLE     |
    |   QUEUEABLES    |   |   QUEUEABLES    |
    |  (concurrent)   |   |  (sequential)   |
    +-----------------+   +-----------------+

Execution Strategy Comparison

StrategyExecution PatternBest ForLimits
PARALLEL_QUEUEABLESMultiple queueables run concurrentlySmall datasets, fastest executionMax 50 queueables per transaction
CHAINABLESequential queueables, each chains nextMedium datasets, async context1 child per queueable
BATCHBatch Apex with configurable scopeLarge datasets, query-based5 concurrent batches

Architecture Decision Guide

When to Use This Framework

text
+------------------------------------------------------------------------------+
|                        SHOULD I USE ASYNC PROCESSING?                        |
+------------------------------------------------------------------------------+

                    +---------------------------------+
                    |  How many records to process?   |
                    +----------------+----------------+
                                     |
              +----------------------+----------------------+
              |                      |                      |
         < 200 records          200-10,000             > 10,000
              |                      |                      |
              v                      v                      v
    +-----------------+    +-----------------+    +-----------------+
    | Consider sync   |    | Use framework   |    | Use framework   |
    | processing      |    | with AUTO       |    | with BATCH      |
    | (if fast enough)|    | strategy        |    | strategy        |
    +-----------------+    +-----------------+    +-----------------+

                    +---------------------------------+
                    |  Is this a recurring operation? |
                    +----------------+----------------+
                                     |
                    +----------------+----------------+
                    | YES                             | NO
                    v                                 v
          +-----------------+               +-----------------+
          | Use ScheduledJob|               | Use UTIL_Async- |
          | (declarative)   |               | JobLauncher     |
          | or SCHED_* class|               | (ad-hoc)        |
          +-----------------+               +-----------------+

Framework Selection Matrix (for Architects)

ScenarioRecommended ApproachWhy
Trigger processing > 200 recordsUTIL_AsynchronousJobLauncher with AUTOOffload heavy processing, avoid trigger timeouts
Nightly data cleanupScheduledJob__c + SCHED_PurgeRecordsDeclarative, no code deployment for schedule changes
API response processingUTIL_AsynchronousJobLauncher with BATCHHandle large API responses reliably
User-initiated bulk actionUTIL_AsynchronousJobLauncher with AUTOFast for small sets, scales for large
Integration sync (hourly)ScheduledJob__c + custom SCHED_*Configurable, monitorable
One-time data migrationUTIL_AsynchronousJobLauncher with BATCHMaximum throughput, query-based
Email campaign processingUTIL_AsynchronousJobLauncher + calloutsHandles callout limits per transaction

How do I write the processing logic?

Interface Hierarchy

text
+-----------------------------------------------------------------+
|                     IF_Async                                    |
|                     (Container class)                           |
+-----------------------------------------------------------------+
|                                                                 |
|   +-------------------------+   +-------------------------+    |
|   |   Processable       |   |   Finishable        |    |
|   |   (Required)            |   |   (Optional)            |    |
|   +-------------------------+   +-------------------------+    |
|   |                         |   |                         |    |
|   |   execute(List<Object>) |   |   finish(BatchContext)  |    |
|   |                         |   |                         |    |
|   |   Called for each       |   |   Called once after     |    |
|   |   batch/chunk of items  |   |   all items processed   |    |
|   |                         |   |                         |    |
|   +-------------------------+   +-------------------------+    |
|                                                                 |
|   +---------------------------------------------------------+  |
|   |   AsynchronousExecutionStrategy (Enum)                   |  |
|   +---------------------------------------------------------+  |
|   |   AUTO              - Framework decides (recommended)    |  |
|   |   BATCH             - Force Batch Apex                   |  |
|   |   CHAINABLE         - Force chained queueables           |  |
|   |   PARALLEL_QUEUEABLES - Force parallel queueables        |  |
|   +---------------------------------------------------------+  |
|                                                                 |
+-----------------------------------------------------------------+

IF_Async.Processable (Required)

This is where your work lives. Implement this interface to define what happens to each batch of records the framework hands you.

apex
public with sharing class MyProcessor implements IF_Async.Processable
{
	/**
	 * @description Called for each batch/chunk of items.
	 *              For BATCH strategy: called once per batch execution
	 *              For QUEUEABLE strategies: called once per queueable
	 *
	 * @param items List of objects to process (cast to your type)
	 */
	public void execute(List<Object> items)
	{
		// Cast to your specific type
		List<Account> accounts = (List<Account>)items;

		// Your business logic here
		for(Account account : accounts)
		{
			// Process each record
		}

		// Perform DML
		DML_Builder.newTransaction().doUpdate(accounts).execute();
	}
}

IF_Async.Finishable (Optional)

Add this interface alongside IF_Async.Processable when you need to clean up or send a notification once all the records are processed. The framework calls your finish() method one time, after the last batch.

apex
public with sharing class MyProcessor implements
	IF_Async.Processable,
	IF_Async.Finishable
{
	private Integer totalProcessed = 0;
	private List<String> errors = new List<String>();

	public void execute(List<Object> items)
	{
		List<Lead> leads = (List<Lead>)items;
		totalProcessed += leads.size();
		// ... processing
	}

	/**
	 * @description Called once after all batches/queueables complete.
	 *              Use for: notifications, status updates, chaining jobs
	 *
	 * @param context Provides job ID for monitoring
	 */
	public void finish(Database.BatchableContext context)
	{
		// Log completion
		LOG_Builder.build().info(
			'Processed ' + totalProcessed + ' leads with ' + errors.size() + ' errors'
		).emitAt('MyProcessor.finish');

		// Update status record
		ProcessingStatus__c status = new ProcessingStatus__c(
			JobId__c = context.getJobId(),
			RecordsProcessed__c = totalProcessed,
			Status__c = 'Complete'
		);
		DML_Builder.newTransaction().doInsert(status).execute();
	}
}

Async Chain Orchestration

Some work has distinct phases that must run in order, where one phase is too much to fit in a single transaction with the next. An async chain runs those phases as separate steps, one after another, each in its own transaction. Steps share data, progress is tracked automatically, and errors are handled for you. Because each step is a fresh transaction, it starts with a clean set of governor limits.

When to Use Chains

PatternBest for
Single processor (IF_Async.Processable)Processing a collection of records with the same logic
Scheduler (IF_Schedulable)Recurring jobs on a cron schedule
Async chainMulti-step workflows where steps must run sequentially, each needing fresh governor limits

Use chains when your workflow has distinct phases that depend on each other: for example, load data, transform it, then send a notification. Each step can make callouts, run DML, and query without competing for the same transaction's limits.

Architecture

text
+-----------------------------------------------------------------------------------+
|                          ASYNC CHAIN ORCHESTRATION                                |
+-----------------------------------------------------------------------------------+
|                                                                                   |
|   +-------------------+        +--------------------------+                       |
|   |   ChainBuilder    |        | AsyncChainExecution__c   |                       |
|   |                   |        |                          |                       |
|   |   .then(step)     +------->|   ChainName, Status,     |                       |
|   |   .onError(h)     |  DML   |   StepLog,       |                       |
|   |   .onComplete(h)  | Insert |   ContextData,           |                       |
|   |   .execute()      |        |   CorrelationId          |                       |
|   +-------------------+        +------------+-------------+                       |
|                                             |                                     |
|                                    System.enqueueJob()                            |
|                                             |                                     |
|   +-------------------------------------------------------------------------+     |
|   |                     QUEUEABLE TRANSACTION (per step)                    |     |
|   +-------------------------------------------------------------------------+     |
|   |                                                                         |     |
|   |   +------------------+      +------------------+      +--------------+  |     |
|   |   | ChainExecutor    |      | Your ChainStep   |      | StepResult   |  |     |
|   |   |                  |      |                  |      |              |  |     |
|   |   | 1. Hydrate log   +----->| work(context)    +----->| success=true |  |     |
|   |   |    context       |      |                  |      | message=...  |  |     |
|   |   | 2. Deserialize   |      | - DML operations |      | data=...     |  |     |
|   |   |    chain context |      | - API callouts   |      +--------------+  |     |
|   |   | 3. Run step      |      | - Context reads  |                        |     |
|   |   | 4. Persist state |      | - Context writes |                        |     |
|   |   +------------------+      +------------------+                        |     |
|   |                                                                         |     |
|   +-----------------------------------+-------------------------------------+     |
|                                       |                                           |
|                              +--------+--------+                                  |
|                              |    FINALIZER     |                                  |
|                              +---------+--------+                                  |
|                                        |                                          |
|                    +-------------------+-------------------+                       |
|                    |                                       |                       |
|              SUCCESS + more steps                   UNHANDLED EXCEPTION            |
|                    |                                       |                       |
|                    v                                       v                       |
|       +------------------------+              +------------------------+           |
|       | Enqueue next           |              | Mark chain FAILED      |           |
|       | ChainExecutor          |              | Log crash details      |           |
|       | (fresh governor limits)|              +------------------------+           |
|       +------------------------+                                                  |
|                    |                                                              |
|              (repeat until last step)                                             |
|                    |                                                              |
|                    v                                                              |
|       +------------------------+                                                  |
|       | handleChainCompletion  |                                                  |
|       |                        |                                                  |
|       | Run onComplete handler |                                                  |
|       | Mark chain COMPLETED   |                                                  |
|       +------------------------+                                                  |
|                                                                                   |
+-----------------------------------------------------------------------------------+

Execution Flow:

  1. ChainBuilder validates and persists the chain configuration to AsyncChainExecution__c
  2. ChainExecutor (a Queueable) hydrates logging context, deserializes chain state, runs one step
  3. The Finalizer (fresh governor limits) enqueues the next ChainExecutor for the next step
  4. Context is serialized to AsyncChainExecution__c.ContextData__c between transactions
  5. On unhandled crash (governor limits), the Finalizer marks the chain as Failed

Error Flow:

text
+------------------+     +------------------+     +------------------+
| Step fails       |     | Error handler    |     | Final state      |
| (StepResult or   +---->| onError(handler) +---->| Status = Failed  |
|  exception)      |     | own Queueable    |     | ErrorMessage set |
+------------------+     +------------------+     +------------------+

+------------------+     +------------------+     +------------------+
| Step fails with  |     | Chain continues  |     | Next step runs   |
| continueOnError  +---->| to next step     +---->| with fresh       |
| = true           |     | (logged as warn) |     | governor limits  |
+------------------+     +------------------+     +------------------+

Building Steps

Each step in a chain is a small class you write. Extend UTIL_AsyncChain.ChainStep and implement work(ChainContext), then return a StepResult to say whether the step succeeded or failed.

apex
public class LoadDataStep extends UTIL_AsyncChain.ChainStep
{
	public override UTIL_AsyncChain.StepResult work(UTIL_AsyncChain.ChainContext context)
	{
		List<Account> accounts = new SEL_Account().toList();
		context.put('accountCount', accounts.size());
		context.put('accountIds', new Map<Id, Account>(accounts).keySet());
		return UTIL_AsyncChain.succeeded('Loaded ' + accounts.size() + ' accounts');
	}
}
apex
public class TransformDataStep extends UTIL_AsyncChain.ChainStep
{
	public override UTIL_AsyncChain.StepResult work(UTIL_AsyncChain.ChainContext context)
	{
		Integer count = (Integer)context.get('accountCount');

		if(count == 0)
		{
			return UTIL_AsyncChain.failed('No accounts to transform');
		}

		context.put('transformed', true);
		return UTIL_AsyncChain.succeeded('Transformed ' + count + ' accounts');
	}
}

StepResult factories:

MethodUse
UTIL_AsyncChain.succeeded()Success, no message
UTIL_AsyncChain.succeeded(message)Success with descriptive message
UTIL_AsyncChain.succeeded(message, data)Success with message and payload data
UTIL_AsyncChain.failed(message)Failure with descriptive message
UTIL_AsyncChain.failed(exception)Failure from caught exception

Chain Builder API

You configure a chain with a series of short chained calls, then one call runs it. Start with UTIL_AsyncChain.newChain():

apex
String executionId = UTIL_AsyncChain.newChain('OrderProcessing')
	.then(new ValidateOrderStep())
	.then(new ProcessPaymentStep())
	.then(new SendConfirmationStep())
	.withInitialContext('orderId', order.Id)
	.withInitialContext('amount', order.Total__c)
	.onError(new NotifyAdminStep())
	.onComplete(new AuditLogStep())
	.execute();
MethodDescription
.then(IF_Chain.Step)Appends a step to the chain (accepts interface or ChainStep)
.then(IF_Chain.Step, Boolean)Appends a step with explicit continueOnError control
.withInitialContext(key, value)Seeds the context with a key-value pair (additive)
.withMaxSteps(Integer)Maximum steps allowed (default: 50)
.withDelayMinutes(Integer)Delays the first step by 0 to 10 minutes, best-effort (see below)
.withDeduplicationKey(String)Blocks a second overlapping run while one with the same key is active (see Preventing duplicate runs)
.withAsyncOptions(AsyncOptions)Sets queueable stack depth (for tests)
.onError(IF_Chain.Step)Registers a handler that runs when a step fails
.onComplete(IF_Chain.Step)Registers a handler that runs after all steps succeed
.execute()Persists config, enqueues the first step, returns the AsyncChainExecution__c ID
.execute(correlationId)Same as above with a caller-supplied correlation ID

Delaying the first step. withDelayMinutes holds the chain's first step back for a few minutes, which is useful for spacing work out or giving an upstream system a moment to settle. Treat it as "about N minutes" rather than a precise timer, and keep these limits in mind:

  • The delay is capped at 0 to 10 minutes, the platform's own ceiling. A value outside that range is clamped, and 0 or no value starts the chain immediately, exactly as before.
  • Only the first step waits. Every later step still runs as soon as the one before it finishes.
  • If your org has turned the platform's queued-job delay off, the chain simply starts straight away.
  • While it waits, the Chain Monitor shows the chain as Running with 0 of N steps done. It has not stalled; it is waiting to begin.
apex
UTIL_AsyncChain.newChain('NightlyRollup')
	.then(new AggregateStep())
	.withDelayMinutes(5)
	.execute();

Context Sharing

Steps pass data to each other through a shared ChainContext object. Because that object is saved (serialized) between transactions, every value you put in it must be JSON-serializable. Prefer record IDs and simple values over full SObject graphs, which are heavier to carry across each transaction boundary.

apex
public override UTIL_AsyncChain.StepResult work(UTIL_AsyncChain.ChainContext context)
{
	context.put('batchSize', 200);
	context.put('recordId', record.Id);

	Integer batchSize = (Integer)context.get('batchSize');
	Boolean hasKey = context.has('recordId');
	List<String> names = (List<String>)context.getAs('nameList', List<String>.class);

	UTIL_AsyncChain.StepResult previous = context.getPreviousStepResult();
	Integer stepIndex = context.getCurrentStepIndex();
	String executionId = context.getChainExecutionId();
	String correlationId = context.getCorrelationId();

	return UTIL_AsyncChain.succeeded();
}
MethodReturnsDescription
get(key)ObjectRaw value lookup
getAs(key, Type)ObjectDeserializes to specified type (for complex objects crossing transaction boundaries)
put(key, value)voidStores a value
has(key)BooleanChecks key existence
getPreviousStepResult()StepResultResult of the last completed step (null for first step)
getCurrentStepIndex()IntegerZero-based index of the current step
getChainExecutionId()StringID of the AsyncChainExecution__c tracking record
getCorrelationId()StringCorrelation ID for log tracing
getAttempt()IntegerWhich attempt this is for the current step, starting at 1 (see Retrying a failed step)
isFinalAttempt()BooleanTrue on the current step's last allowed attempt (for your own ChainStep subclasses)

Error Handling

onError handler: This runs when any step fails (unless the failing step is marked continueOnError = true). It runs in its own fresh transaction (HandlerExecutor), so it starts with clean governor limits and can make callouts even when the failed step already performed DML. It receives the full context, including the failure result from the step that broke.

apex
public class NotifyAdminStep extends UTIL_AsyncChain.ChainStep
{
	public override UTIL_AsyncChain.StepResult work(UTIL_AsyncChain.ChainContext context)
	{
		UTIL_AsyncChain.StepResult failedResult = context.getPreviousStepResult();
		String errorMessage = failedResult != null ? failedResult.message : 'Unknown error';

		LOG_Builder.build().error('Chain failed: ' + errorMessage)
			.at('NotifyAdminStep.work')
			.forRecord(context.getChainExecutionId())
			.emit();

		return UTIL_AsyncChain.succeeded();
	}
}

continueOnError: When a step is allowed to fail without stopping the whole chain, pass true to the then(step, true) overload. The chain logs the failure and moves on to the next step.

apex
UTIL_AsyncChain.newChain('ResilientChain')
	.then(new CriticalStep())
	.then(new CleanupStep(), true)
	.then(new FinalStep())
	.execute();

onComplete handler: This runs after every step has succeeded, again in its own fresh transaction (HandlerExecutor). Callouts are safe here even if the final step performed DML. If this handler throws, the chain is marked as Failed.

Chain statuses: RunningCompleted | Failed | Aborted, with Stalled as a recoverable in-between state when a chain loses its background job (see Monitoring).

Retrying a failed step

When a step fails for a temporary reason (a locked row, a brief callout timeout, a rate limit), you often want it to try again rather than fail the whole chain. By default a step gets one attempt: if it throws, the chain stops and is marked Failed. A step can opt into automatic retry instead.

You opt in by overriding createRetryStrategy() on your step and returning a retry strategy. Return null, which is the default, and nothing changes: one attempt, no retry, exactly as before. This matters on upgrade: a step that was never written to be safe to run twice is never retried behind your back.

apex
public class ChargeCardStep extends UTIL_AsyncChain.ChainStep
{
	public override UTIL_AsyncChain.StepResult work(UTIL_AsyncChain.ChainContext context)
	{
		// ... do the work ...
		return UTIL_AsyncChain.succeeded();
	}

	public override UTIL_Retry.Strategy createRetryStrategy()
	{
		return UTIL_Retry.exponential().withMaxRetries(2).withBaseBackoff(0);
	}
}

Build the strategy with UTIL_Retry.exponential() (or linear()) and shape it with withMaxRetries, withBaseBackoff, and the other options. To retry only certain failures, wrap it in an allowlist so an unexpected exception fails fast instead of being retried pointlessly:

apex
public override UTIL_Retry.Strategy createRetryStrategy()
{
	return UTIL_Retry.retryOnlyOnException(
		UTIL_Retry.exponential().withMaxRetries(3),
		new Set<Type>{ CalloutException.class }
	);
}

Prefer this allowlisted form over any "just try N times" shortcut. You decide which failures are worth retrying, and a genuine bug surfaces immediately rather than being retried three times first.

A retry waits at least a minute. The chain schedules each retry through the platform's delayed-job window, which counts in whole minutes and tops out at ten. Any backoff above zero therefore waits a full minute at least; a base backoff of 0 re-enqueues immediately. Size your backoff in that light, because seconds-level precision is not available here.

Know which attempt you are on. Inside work(), context.getAttempt() gives the attempt number (1 for the first try) and context.isFinalAttempt() is true on the last allowed attempt, so a step can, for example, alert someone only when it is about to give up for good. These two read correctly for your own ChainStep subclasses. getPreviousStepResult() on a retried attempt returns that step's own previous failed result, so you can see what went wrong last time.

When a chain runs out of retries it is marked Failed, and its FailureCategory__c reads Retries Exhausted rather than Step Exception, so the audit trail shows the difference between a step that failed once and one that used up every attempt.

Before you turn retry on, check the step is safe to repeat:

  • Running it twice produces the same result (it is idempotent). If it writes to an outside system, pair it with idempotencyKey() so a repeat is not double-counted.
  • A duplicate external side effect is either impossible or harmless.
  • The step still behaves correctly if recovery re-drives the chain while a retry is waiting (see Preventing duplicate runs and Monitoring).

Preventing duplicate runs

Some chains should never run twice at once. A nightly job can fire twice when a schedule overlaps; an action button can be double-clicked. Give the chain a deduplication key and the framework lets only one run with that key be active at a time.

apex
UTIL_AsyncChain.newChain('NightlyAccountSync')
	.then(new SyncAccountsStep())
	.withDeduplicationKey('nightly-account-sync')
	.execute();

While a run with that key is active, a second execute() using the same key does not start a new chain. It lands on the run already in flight and returns that run's Id, so the caller gets a handle to the live chain rather than a duplicate. Keys are compared without regard to case, so 'Nightly' and 'NIGHTLY' count as the same key. A blank key, or one longer than 255 characters, is rejected when you build the chain, not later at run time.

The key is tied to the run's state, never to a clock: there is no expiry timer. The key is released the moment the chain reaches any end state (Completed, Failed, or Aborted), so the next scheduled run is free to start. A stalled chain keeps its key until recovery finishes or it resolves, which is deliberate: releasing it on a timer would reopen the very overlap the key exists to prevent.

This is not the same as idempotencyKey(). The two sound alike and do opposite jobs. A deduplication key stops two runs of the chain from overlapping. An idempotency key makes one step's external side effect safe to replay, so a retry does not double-charge a card or double-post a record. Use both together when a chain must not overlap and also contains a step that writes to an outside system. If what you need is a limit on how often a chain may run in sequence rather than in parallel, read the current run's state with getChainStatus and decide in your own code; the deduplication key is only about overlap.

A natural key for a scheduled chain is the schedule's own name, so every firing of that schedule shares one key and can never lap itself.

Monitoring

To see how a chain is doing, you can either ask for its status in code or open the AsyncChainExecution__c records directly. Every chain you run leaves one of these records behind.

apex
Map<String, Object> status = UTIL_AsyncChain.getStatus(executionId);
String currentStatus = (String)status.get('status');
Decimal completedSteps = (Decimal)status.get('completedSteps');
Decimal totalSteps = (Decimal)status.get('totalSteps');

The map is handy for a quick read, but you have to know each key's name and cast every value yourself. When you would rather work with a typed object, use getChainStatus instead. It returns a ChainStatus with every field named and typed, plus three ready-made checks so you never compare status strings by hand:

apex
UTIL_AsyncChain.ChainStatus status = UTIL_AsyncChain.getChainStatus(executionId);

if(status != null && status.isFailed())
{
	String reason = status.errorMessage;
}

Integer done = status.completedSteps;   // already an Integer, no casting
String elapsed = status.durationLabel;  // for example "1m 30s"

isRunning(), isTerminal(), and isFailed() answer the common questions directly. The raw status is still there as a String if you need it. The framework writes five values: Running (a chain waiting on a scheduled delay also shows as Running, with no completed steps yet), Completed, Failed, Aborted, and Stalled. A chain goes Stalled when it loses the background job that would carry it forward, so recovery is either waiting to step in or already acting: the watchdog can revive a stalled chain and let it finish. isTerminal() reports the end states Completed, Failed, and Aborted, and also any chain that carries a completion timestamp. That last case catches the rare row where recovery briefly overwrites an already-finished chain back to Stalled: the completion timestamp survives, so it stays the reliable end-of-life signal. When a step throws an unhandled exception, the chain is marked Failed straight away, with no automatic retry and no backoff, so a Failed chain stays Failed until you relaunch it yourself: recovery is reserved for a chain that stalls, never one that has already failed. For a chain that is still running, durationMs is filled in live from its start time, so it matches what the Chain Monitor shows.

Turn on stalled-chain recovery. The watchdog that revives stalled chains (it safely restarts the ones it can prove died and flags anything it cannot prove dead for a person) is itself a scheduled job, and a managed package cannot ship one, so it stays dormant until an admin schedules it. The quickest way is one click: the Health Check on the Kern app's Home tab spots a missing watchdog and schedules it for you (hourly, with a 30-minute stale threshold and up to 50 chains per sweep). Until you do, only one class of stall surfaces on its own: a chain whose next step could not be queued is marked Stalled immediately, but a run whose background job died without leaving a trace stays on Running until the watchdog's sweep picks it up, restarting it when it can prove the job died or marking it Stalled when it cannot, and neither class is re-driven until the watchdog is scheduled. Schedule it once, the same declarative way as the built-in housekeeping jobs: create a ScheduledJob__c record whose ClassName__c is SCHED_ChainWatchdog, running hourly (0 0 * * * ?); see Declarative Scheduling with ScheduledJob__c. Two optional attributes tune it: StaleThresholdMinutes (how many minutes a chain may sit idle before it counts as stalled, default 30) and BatchSize (how many chains one sweep inspects, default 50). If you would rather schedule it in Apex, System.schedule('Chain Watchdog', '0 0 * * * ?', new SCHED_ChainWatchdog()); does the same job. The Installation guide lists this next to the other one-time setup steps.

A ChainStatus is a single-record summary and deliberately carries no per-step list. When you need step-by-step detail, open the chain in the Chain Monitor. To look up several chains at once, getChainStatuses(Set<Id>) returns a map of Id to ChainStatus in one query, so it is safe to call inside a loop. Both accessors return only the chains you have access to, exactly as getStatus does.

AsyncChainExecution__c fields:

FieldDescription
ChainName__cDescriptive name from newChain()
ActiveDeduplicationKey__cThe deduplication key while the chain is active, if you set one; cleared when the chain reaches an end state (see Preventing duplicate runs). Empty when no key was set
Status__cRunning, Completed, Failed, Aborted, or Stalled (the picklist also defines Delayed, which the framework does not currently write)
FailureCategory__cWhy the chain left the happy path: Step Exception, Retries Exhausted, Kill Switch, Stalled, or Operator Abort. Empty on healthy chains
TotalSteps__cNumber of steps in the chain
CompletedSteps__cSteps that succeeded (failed steps are not counted)
CurrentStepName__cName of the currently executing step
ErrorMessage__cError details if failed; non-fatal failure summaries if completed with issues
DurationMs__cTotal chain execution duration in milliseconds
StepLog__cJSON log of each step: className, success, durationMs, message (enriched at runtime)
CorrelationId__cCorrelation ID for log tracing
ContextData__cSerialized chain context (JSON)
StartedAt__cWhen the chain started
CompletedAt__cWhen the chain finished

The object has field history tracking enabled on Status__c, CompletedSteps__c, CurrentStepName__c, and CompletedAt__c, providing a full audit trail of chain progression step by step.

Stopping a chain from the Chain Monitor

Sometimes a chain is doing the wrong thing and you want it to stop now, not after you have tracked down the cause. The Chain Monitor has an Abort action for exactly that. It ships switched off, so no one can stop a chain by accident until you deliberately turn it on.

Turning it on. There is nothing to activate. Assign the Kern Async Chain Abort permission set to the operations users who should be allowed to abort a chain, and the action appears for them. The feature flag that governs it ships active, so the permission set is the only switch you touch to enable the action. One caveat: installing the package for All Users writes the abort permission into every profile during the install, so in that case the action is already enabled for everyone. Remove those profile grants to bring the action back under the permission set's control.

Turning it off for everyone. Deactivate the Async Chain Abort Enabled feature-flag record. That is the org-wide off-switch: it hides the action for every user in one step, with no deployment and without touching anyone's permission set. The Health Check's Chain Abort Capability row tells you where you stand, warning while the flag is deactivated, while profiles beyond System Administrator hold the abort permission (the footprint of an All Users install), or while no one holds the permission set. It passes once the flag is active, no profile beyond System Administrator holds the permission, and at least one user holds the set.

What abort does. Abort takes effect at the next step boundary. The step that is running is allowed to finish, and then the chain stops rather than starting the next one (it can also stop a stalled chain that is waiting on recovery). An aborted chain is final: it cannot be resumed, so relaunch it from the start if the work still needs to run. The outcome is recorded as Aborted, with a failure category of Operator Abort.

A note on live updates. The admin who presses Abort always sees their own monitor update straight away. On an org whose logging is turned down to errors only, a common production setting, other admins watching the same chain will not see the abort appear on its own; they refresh the panel to pick it up. A failure still shows up live either way.

Logging Strategy

The chain framework keeps logs quiet so the ones that appear matter. Ordinary progress (Status, CompletedSteps, CurrentStepName, DurationMs) is tracked on the AsyncChainExecution__c record, not written to logs. CompletedSteps__c counts only the steps that succeeded; failed steps (including ones marked continueOnError) are not counted. Logs are reserved for events you can act on:

EventLevelWhen
Step exception (stack trace)ErrorStep throws an unhandled exception
Step failed but continuingWarnA continueOnError step fails; the log includes the failure reason and duration
Chain crashedErrorFinalizer catches unhandled Queueable crash (governor limits)
Chain completedInfoThe chain finished successfully; one entry, so a quiet chain is no longer invisible
Chain failedErrorThe chain reached its terminal Failed state (the outcome marker, separate from any step-level exception above)
Chain abort requestedWarnAn operator aborts a chain; the running step finishes, then the chain stops
Chain abortedWarnKill switch active, max steps exceeded, or an operator aborted it from the Chain Monitor
Execution record deletedWarnRare: a record was removed externally while the chain was running

Seeing terminal signals in the Chain Monitor. Every chain now leaves one log entry when it ends, marking how it finished: an INFO entry for a completion, an ERROR entry for a failure, and a WARN entry for an abort. This closes the old gap where a healthy chain finished without a trace. Where those entries show up depends on your log level. ERROR entries clear the log-level threshold whatever it is set to, so a failure is not hidden by a high threshold. A completion (INFO) or an abort (WARN) is written only when logging is enabled and the threshold is set to include that level. At the ERROR threshold many orgs run in production, completions and aborts are filtered out, so a second admin watching the same chain in the Chain Monitor will not see a completion or an abort appear on its own; a failure still appears live. The entry is best-effort: the chain records its own outcome first and the log follows, so a delivery hiccup never changes the chain's result.

Performance logging happens automatically through UTIL_PerformanceTimer. Each step is timed, and if a step runs longer than the threshold in LogSetting__c.PerformanceThresholdMs__c, the framework writes a structured performance log showing the CPU, heap, SOQL, and DML it used. There is nothing to configure by hand: just enable LogSetting__c.EnablePerformanceLogging__c.

Non-fatal failure tracking: When continueOnError steps fail, their failure summaries are added to ErrorMessage__c on the execution record (for example, "Non-fatal step failures: CleanupStep: Timeout; OptionalStep: Service unavailable"). The status stays Completed, so admins can filter on ErrorMessage__c to find chains that finished but had problems along the way.

Log Correlation

A correlation ID is one tracking ID that follows a single user action across triggers, queries, callouts, and jobs, so you can pull every log from one action together later. Chains inherit the current LOG_Builder correlation context automatically. When a chain spans several Queueable transactions, the framework calls LOG_Builder.serializeContext() and hydrateContext() for you behind the scenes, carrying the correlation ID, the parent transaction ID, and any custom context data across each transaction boundary.

Because every log a chain's steps write shares the same correlation ID, you can trace a whole chain run in one place: App Launcher > Kern > Log Entries.

To supply a specific correlation ID:

apex
UTIL_AsyncChain.newChain('MyChain')
	.then(new MyStep())
	.execute('my-custom-correlation-id');

Step Design Guidance

Context key naming: Use <StepName>.<key> to avoid collisions between steps:

apex
context.put('CreateAccount.accountId', account.Id);
context.put('SendEmail.messageId', response.messageId);

Safe to run twice (idempotency): A step is idempotent when running it a second time produces the same end state as running it once, with no duplicate side effects, so running the same step twice does not double-process anything. The framework does not do this for you; it is the step author's job, because manual reprocessing, multiple entry points, or a partial failure followed by a re-run can all make a step fire again.

What makes a step safe to re-run is a key that stays identical every time the same step replays. Never build that key from a value that changes between attempts, such as a fresh timestamp or a new job id, or the protection silently disappears. The context hands you the right key so you cannot get this subtly wrong.

The default: one key per step. For a step that does a single unit of work, call context.idempotencyKey(). Put the returned value on an external-id field (a field you mark as an external id so a record can be matched by it) and upsert (insert-or-update in one call), so a replay updates the same record instead of creating a duplicate:

apex
Invoice__c invoice = new Invoice__c(
	IdempotencyKey__c = context.idempotencyKey(),
	Amount__c = 100
);
upsert invoice IdempotencyKey__c;

For a side effect you cannot upsert, such as a callout or an email, stamp that same key on a marker record first and skip the work when the marker is already there.

For a step that loops over many records: one key per record. Pass the record id with context.idempotencyKey(recordId). This matters when a bulk step dies halfway and replays: with a single step-level key you would have to re-run every record or skip them all, whereas a per-record key means the replay only touches the rows that did not make it the first time.

apex
List<Invoice__c> invoices = new List<Invoice__c>();
for(Account account : accounts)
{
	invoices.add(new Invoice__c(
		IdempotencyKey__c = context.idempotencyKey(account.Id),
		Account__c = account.Id
	));
}
upsert invoices IdempotencyKey__c;

Treat the key as opaque (use it whole; do not pull it apart). Compare it, or store it as your external-id value, but do not split it to read back the run or the record. If you need those, you already have them: the record id you passed in, and the values the context exposes.

Keep a custom grain short. When the fan-out unit is not a record id, pass your own short, stable token with context.idempotencyKey('orderLine-7'). An external-id field holds up to 255 characters, so keep the token short; if you ever need a long one, shorten it yourself first (a one-line hash of just your token) while keeping the readable run-and-step prefix. An over-long key does not pass silently: the save fails with a clear "value too large" error, so you find out immediately.

In one line: the chain run is the namespace, the step is the unit of replay, and the record is the grain when a step is bulk.

Kill Switch

If chains start misbehaving in production, you want to stop them without a deployment. The FeatureFlag.AsyncChain custom metadata record is that master off-switch you can flip in an incident. When you disable it, any running chain aborts at once with the message "Kill switch active" and no further steps are enqueued.

It is read through UTIL_FeatureFlag.isEnabled('AsyncChain') and ships enabled by default, so chains work out of the box.

ApiStep: Web Service Integration

If you already have an outbound integration written as an API_Outbound handler, you can drop it into a chain as one step without changing the handler at all. UTIL_AsyncChain.ApiStep does the bridging. It works with non-POST verbs too (override getHttpMethod() in the handler; see Web Services - Guide). Behind the scenes it runs the full web service lifecycle for you (validation, the callout, parsing the response, DML, and saving an ApiCall__c record) through UTIL_HttpClient delegation mode.

How it works: Chain steps are saved by class name and rebuilt later, so an ApiStep can't keep its settings on the step object itself. Instead, its configuration is stored in the ChainContext. When you build the chain, the ChainBuilder writes the step's configuration into the initial context; when the step runs, work() reads it back using the step's position in the chain.

Basic Usage

apex
UTIL_AsyncChain.newChain('OrderProcessing')
    .withInitialContext('orderId', order.Id)
    .then(new UTIL_AsyncChain.ApiStep(API_ChargePayment.class)
        .triggeringRecordFrom('orderId')
        .withParameter(API_ChargePayment.PARAM_AMOUNT, '99.99'))
    .then(new UTIL_AsyncChain.ApiStep(API_SendConfirmation.class)
        .triggeringRecordFrom('orderId')
        .withParameterFrom('recipient', 'customerEmail'))
    .onError(new NotifyAdminStep())
    .execute();

Builder Methods

MethodPurpose
new ApiStep(Type)Wrap an API_Outbound subclass
.credential(String)Override the Named Credential
.withParameter(name, value)Pass a static parameter to the handler
.withParameterFrom(paramName, contextKey)Resolve a parameter from a prior step's output
.triggeringRecord(Id)Static triggering record ID
.triggeringRecordFrom(contextKey)Read triggering record ID from context
.withRetry(maxAttempts, baseBackoffSeconds)Retry the whole call automatically, up to maxAttempts tries (see below)

Retrying an API step. An ApiStep can retry a failed call itself, the chain equivalent of a ChainStep opting in with createRetryStrategy(). Pass the total number of attempts including the first, and a base backoff in seconds: .withRetry(3, 30) allows up to three tries. When you set this, the chain owns the retry and the outbound engine's own rescheduling is switched off for that step, so the call is retried once by the chain, never twice by both. Leave withRetry off and the call behaves exactly as before, with the outbound engine keeping sole charge of retries. The same whole-minute backoff rule applies as for step retries.

Reading Results from Downstream Steps

After an ApiStep runs, its result is stored in the context under the key __apiResult_{stepIndex} as a Map<String, Object> with these keys: success (Boolean), statusCode (Integer), apiCallId (String), and errors (String, present only on failure). The full response body lives on the ApiCall__c record, so a later step can query it by ID when it needs the body.

apex
public class ProcessPaymentResultStep extends UTIL_AsyncChain.ChainStep
{
    public override UTIL_AsyncChain.StepResult work(UTIL_AsyncChain.ChainContext context)
    {
        Map<String, Object> paymentResult = (Map<String, Object>)context.get('__apiResult_0');
        Boolean success = (Boolean)paymentResult.get('success');
        String apiCallId = (String)paymentResult.get('apiCallId');
        // Full response body is on ApiCall__c — query by apiCallId if needed
        return UTIL_AsyncChain.succeeded('Payment result processed');
    }
}

Standalone vs. Chain Execution

You can call the same API_Outbound handler on its own or as a chain step. Either way, the handler itself stays the same:

ModeEntry PointLifecycle
StandaloneUTIL_HttpClient.useHandler(API_SendEmail.class).withParameter(...).invoke()Synchronous, caller controls
Chain step.then(new UTIL_AsyncChain.ApiStep(API_SendEmail.class).withParameter(...))Async, chain controls error/retry

Error Handling

When the API call fails, the ApiStep returns UTIL_AsyncChain.failed() carrying the handler's error messages. Your chain's onError() handler then fires (if you configured one), and the ApiCall__c record is still saved for audit. Retries that happen inside the step's own transaction run normally via UTIL_HttpClient. Async retries are not scheduled, because the chain, not the handler, owns what happens on failure.

Use continueOnError when a failed API call should not stop the chain:

apex
.then(new UTIL_AsyncChain.ApiStep(API_SendNotification.class)
    .triggeringRecordFrom('orderId'), true)  // non-fatal

Testing Chains

Chains run as Queueables, so wrap the execution in Test.startTest() / Test.stopTest(). For a chain with more than one step, also provide AsyncOptions (the platform built-in System.AsyncOptions, not a kern-namespaced type) and set maximumQueueableStackDepth to match your chain's depth. That tells the platform it may run the chained Queueables one after another inside the test.

The AsyncChain feature flag ships with IsEnabledByDefault__c = true, so chains are on by default. You don't need to seed anything to enable them in your tests.

apex
@IsTest
private static void shouldCompleteThreeStepChain()
{
	AsyncOptions options = new AsyncOptions();
	options.maximumQueueableStackDepth = 4;

	Test.startTest();
	String executionId = UTIL_AsyncChain.newChain('TestChain')
		.then(new LoadDataStep())
		.then(new TransformDataStep())
		.then(new NotifyStep())
		.withAsyncOptions(options)
		.execute();
	Test.stopTest();

	Map<String, Object> status = UTIL_AsyncChain.getStatus(executionId);
	Assert.areEqual('Completed', (String)status.get('status'), 'Chain should complete');
	Assert.areEqual(3, (Decimal)status.get('totalSteps'), 'Should have 3 steps');
}

Why maximumQueueableStackDepth? In production the framework sets the stack depth to steps.size() + 1 automatically. In tests, Salesforce defaults to a depth of 1, which blocks chained Queueables from running. Pass withAsyncOptions() to raise that limit for the test.

The framework creates and wires up the ChainContext for you. You never construct it yourself. Drive chains through newChain().then().execute() inside Test.startTest()/Test.stopTest(), and the framework builds the context and hands it to each step. If you need to read or assert on the context, do it from inside your step's work(ChainContext context) override (where the context is handed to you), then check the chain's outcome with UTIL_AsyncChain.getStatus(executionId).


Scheduler Framework

When you need a job to run on a recurring schedule (nightly, hourly, every Monday), you have two routes. The recommended one lets admins create and change schedules as configuration records, with no code deployment. The other is the platform's own System.schedule() call, for full programmatic control. This section covers both.

Scheduler Architecture

text
+---------------------------------------------------------------------------+
|                           SCHEDULER FRAMEWORK                             |
+---------------------------------------------------------------------------+
|                                                                           |
|   +-------------------------------------------------------------------+  |
|   |                    DECLARATIVE (Recommended)                       |  |
|   |                                                                   |  |
|   |   +-----------------+     +-----------------+     +-----------+   |  |
|   |   | ScheduledJob__c |---->| TRG_ScheduledJob |---->| CronTrigger|  |  |
|   |   | (Custom Object) |     | (Trigger)       |     | (Platform) |  |  |
|   |   |                 |     |                 |     |            |  |  |
|   |   | * ClassName     |     | * Validates     |     | * Executes |  |  |
|   |   | * CronExpr      |     | * Starts/Stops  |     |   on       |  |  |
|   |   | * Attributes    |     | * Updates ID    |     |   schedule |  |  |
|   |   | * Active        |     |                 |     |            |  |  |
|   |   +-----------------+     +-----------------+     +------------+  |  |
|   |                                                                   |  |
|   |   Benefits: No deployment, UI-manageable, audit trail, validation |  |
|   +-------------------------------------------------------------------+  |
|                                                                           |
|   +-------------------------------------------------------------------+  |
|   |                    PROGRAMMATIC                                    |  |
|   |                                                                   |  |
|   |   System.schedule('Job Name', cronExpr, new MyScheduler());       |  |
|   |                                                                   |  |
|   |   Benefits: Full control, script-based, one-off jobs              |  |
|   +-------------------------------------------------------------------+  |
|                                                                           |
|   +-------------------------------------------------------------------+  |
|   |                    SCHEDULER TYPES                                 |  |
|   |                                                                   |  |
|   |   +-------------------+         +-----------------------------+   |  |
|   |   |   Schedulable     |<--ext---|   IF_Schedulable              |   |  |
|   |   |   (Standard)      |         |   (KernDX)                    |   |  |
|   |   +-------------------+         +-----------------------------+   |  |
|   |   |                   |         |                             |   |  |
|   |   |   execute(ctx)    |         |   getParameterDefinitions() |   |  |
|   |   |                   |         |   setParameterValues(DTO)   |   |  |
|   |   |   Fixed behavior  |         |   execute(ctx)              |   |  |
|   |   |                   |         |   (SCHED_Base impl)         |   |  |
|   |   +-------------------+         +-----------------------------+   |  |
|   |                                                                   |  |
|   +-------------------------------------------------------------------+  |
|                                                                           |
+---------------------------------------------------------------------------+

Declarative Scheduling with ScheduledJob__c

This is the recommended approach for managing scheduled jobs in production.

How It Works

You manage scheduled jobs by editing records, and the framework handles the platform plumbing for you:

  1. You create a ScheduledJob__c record with a class name, a cron expression, and any optional attributes.
  2. A trigger fires on insert, update, or delete.
  3. The handler validates that the class exists and implements the right interface.
  4. The job is scheduled automatically once IsActive__c = true.
  5. The job ID is stored in ScheduledJobId__c so you can monitor it.
  6. Changes apply by themselves: updating the record reschedules the job, deleting it aborts the job.

ScheduledJob__c Fields

The full field reference for the scheduling record is below.

Every ScheduledJob__c field
FieldTypeDescription
SchedulerName__cTextHuman-readable name for the job
ClassName__cTextFully qualified class name (with namespace if needed)
CronExpression__cTextStandard Salesforce cron expression
IsActive__cCheckboxWhen true, job runs; when false, job stopped
Parameters__cLong Text Area(131072)JSON-serialized DTO_NameValues containing name/value pairs for configurable schedulers
Description__cLong TextDocumentation of job purpose
Timezone__cTextIANA TimeZoneSidKey of the cron author (e.g., Africa/Johannesburg). Used for timezone-aware scheduling
ScheduledJobId__cTextAuto-populated with CronTrigger ID

Example: Create a Purge Job via UI

apex
// Create a job that purges old log records nightly
ScheduledJob__c purgeJob = new ScheduledJob__c();
purgeJob.SchedulerName__c = 'Purge Old Application Logs';
purgeJob.ClassName__c = 'SCHED_PurgeRecords';
purgeJob.CronExpression__c = '0 0 2 * * ?';  // Daily at 2 AM
purgeJob.IsActive__c = true;
purgeJob.Description__c = 'Deletes LogEntry__c records older than 90 days to manage storage';
purgeJob.Parameters__c = new DTO_NameValues(new Map<String, String>{
	'objectName' => 'LogEntry__c', 'minimumNumberOfDays' => '90', 'batchSize' => '2000'
}).serialize();

DML_Builder.newTransaction().doInsert(purgeJob).execute();
// Job starts automatically!

Common Cron Expressions

ScheduleCron ExpressionDescription
Daily at 2 AM0 0 2 * * ?Every day at 2:00 AM
Hourly0 0 * * * ?Every hour on the hour
Every 15 minutes0 0,15,30,45 * * * ?At :00, :15, :30, :45
Weekly Sunday 1 AM0 0 1 ? * SUNEvery Sunday at 1:00 AM
Monthly 1st at midnight0 0 0 1 * ?First of month at midnight
Weekdays 8 AM0 0 8 ? * MON-FRIMonday-Friday at 8:00 AM

Timezone Awareness

Here is a subtle trap the framework removes for you. System.schedule() reads cron expressions in the running user's timezone, not the author's. So if User A (SAST, UTC+2) sets up a job for noon and User B (PST, UTC-8) later activates it, the job fires at noon PST, 10 hours later than intended. The scheduler framework corrects this automatically.

How it works:

  1. When a ScheduledJob__c record is saved via the LWC editor, Timezone__c is populated with the authoring user's IANA TimeZoneSidKey
  2. When the trigger schedules the job, the framework automatically adjusts the cron hours (and minutes for half-hour timezones like India UTC+5:30) from the stored timezone to the running user's timezone
  3. The adjusted cron is passed to System.schedule(), so the job fires at the author's intended local time of day in the running user's timezone

One caveat, daylight saving. The timezone offset is worked out once, when the job is scheduled, and baked into the stored cron. If the author's zone and the runner's zone move onto or off daylight saving on different dates, the job can fire an hour early or late for the weeks when one zone has shifted and the other has not, until the job is saved again. For a job that must hit an exact wall-clock time all year across such a pair of zones, re-save it after a daylight-saving change.

Edge cases handled:

ScenarioBehaviour
Same timezone (author = runner)No shift needed; the framework short-circuits
Half-hour timezones (e.g., India UTC+5:30)Minutes field also shifted
Day rollover (hours cross midnight)Day-of-week and day-of-month shifted accordingly
Day-of-month boundary (would produce 0 or >31)Day-of-month left unchanged to avoid invalid cron
Wildcard/step hours (*, */2)Not shifted; it fires at regular intervals regardless
L/W day-of-month suffixesNot shifted; relative expressions cannot be reliably shifted

Built-in Schedulers

Four common housekeeping jobs ship ready to use. You schedule them by configuration alone, no code:

SchedulerPurposeKey Attributes
SCHED_PurgeRecordsDelete old recordsobjectName, minimumNumberOfDays, dateFieldName, batchSize
SCHED_DeactivateUsersDeactivate inactive usersprofileNames, minimumNumberOfDays, batchSize
SCHED_PerformBatchedCalloutsProcess queued calloutsbatchSize
SCHED_ProcessLoginHistoryProcess login recordsbatchSize

See Also: DTO_NameValues for attribute parsing utilities.

Creating Custom Configurable Schedulers

When the built-in jobs don't cover your case, you can write your own scheduler that still accepts parameters from a record. Extend SCHED_Base, declare the parameters your job expects, and read their typed values at run time. The example below syncs records with an external system, taking its endpoint, object, and batch size from configuration:

apex
/**
 * @description Custom scheduler that syncs data with external system.
 *              Extends SCHED_Base for runtime configuration with typed parameters.
 */
global inherited sharing class SCHED_ExternalSync extends SCHED_Base
{
	/**
	 * @description Declares the parameters supported by this job.
	 *
	 * @return List of parameter definitions.
	 */
	public override List<DTO_ScheduledParameterDefinition> getParameterDefinitions()
	{
		return new List<DTO_ScheduledParameterDefinition>
		{
				DTO_ScheduledParameterDefinition.of('endpointName').required(),
				DTO_ScheduledParameterDefinition.of('objectName').required(),
				DTO_ScheduledParameterDefinition.of('batchSize').asNumeric().withDefault('200')
		};
	}

	/**
	 * @description Executes the scheduled sync
	 *
	 * @param context The schedulable context
	 */
	public void execute(SchedulableContext context)
	{
		// Get typed configuration from resolved parameters
		String endpointName = getTextParameter('endpointName');
		Integer batchSize = getNumericParameter('batchSize');
		String objectName = getTextParameter('objectName');

		// Query records to sync using QRY_Builder fluent API
		QRY_Builder.Builder query = QRY_Builder.selectFrom(
				UTIL_SObjectDescribe.getSObjectTypeByName(objectName))
			.condition('NeedsSync__c').equals(true);

		// Launch async processing
		UTIL_AsynchronousJobLauncher.DTO_AsynchronousJobRequest request =
			new UTIL_AsynchronousJobLauncher.DTO_AsynchronousJobRequest(query)
				.withBatchSize(batchSize);

		UTIL_AsynchronousJobLauncher.process(request, new SyncProcessor(endpointName));
	}
}

// Configure via ScheduledJob__c:
// Parameters__c = new DTO_NameValues(new Map<String, String>{
//     'endpointName' => 'ExternalCRM', 'objectName' => 'Account', 'batchSize' => '100'
// }).serialize()

Transaction Correlation in Async Operations

When you do this yourself in plain Queueable or Batch code (rather than through a chain), you can still link all the logs from one user action together using a correlation ID, the single tracking ID that follows that action across transactions. LOG_Builder carries that ID across async boundaries for you.

Why Correlation Matters

When a synchronous operation spawns async jobs, the logs from those jobs look unrelated to where they came from. A correlation ID stitches them back together:

text
Without Correlation:                    With Correlation:
---------------------                   -----------------
Log 1: [abc123] Started                 Log 1: [CORR-001] Started
Log 2: [def456] Processing...           Log 2: [CORR-001] Queued async
Log 3: [ghi789] Error!                  Log 3: [CORR-001] Processing... (async)
                                        Log 4: [CORR-001] Error! (async)
(Which logs are related?)               (All logs for CORR-001 are linked!)

Correlation Flow

text
+---------------------------------------------------------------------------+
|                         CORRELATION ACROSS BOUNDARIES                     |
+---------------------------------------------------------------------------+
|                                                                           |
|   TRANSACTION 1 (Sync)              TRANSACTION 2 (Async)                |
|   ---------------------             ---------------------                |
|                                                                           |
|   +---------------------+           +---------------------+              |
|   | startCorrelation()  |           | hydrateContext()    |              |
|   | CorrelationId: ABC  | --------> | CorrelationId: ABC  |              |
|   | TransactionId: T1   |  context  | TransactionId: T2   |              |
|   |                     |  string   | ParentTxnId: T1     |              |
|   +---------------------+           +---------------------+              |
|           |                                   |                           |
|           v                                   v                           |
|   +---------------------+           +---------------------+              |
|   | Log: "Starting..."  |           | Log: "Processing.." |              |
|   | CorrelationId: ABC  |           | CorrelationId: ABC  |              |
|   +---------------------+           +---------------------+              |
|           |                                   |                           |
|           v                                   v                           |
|   +---------------------+           +---------------------+              |
|   | serializeContext()  |           | Log: "Complete"     |              |
|   | Returns: "{...}"    |           | CorrelationId: ABC  |              |
|   +---------------------+           +---------------------+              |
|                                                                           |
|   All logs with CorrelationId = ABC can be queried together              |
|                                                                           |
+---------------------------------------------------------------------------+

Queueable Pattern with Correlation

apex
public with sharing class MyAsyncProcessor implements Queueable
{
	private List<Id> recordIds;
	private String loggerContext;  // Stores serialized correlation context

	public MyAsyncProcessor(List<Id> recordIds)
	{
		this.recordIds = recordIds;
		// CAPTURE context before enqueuing (in sync transaction)
		this.loggerContext = LOG_Builder.serializeContext();
	}

	public void execute(QueueableContext context)
	{
		// RESTORE context at start of async transaction
		LOG_Builder.hydrateContext(loggerContext);

		// All logs now have same CorrelationId as the sync transaction
		LOG_Builder.build().info('Processing ' + recordIds.size() + ' records').emitAt('MyAsyncProcessor.execute');

		// ... processing logic ...

		LOG_Builder.build().info('Processing complete').emitAt('MyAsyncProcessor.execute');
	}
}

// Usage:
LOG_Builder.startCorrelation();  // Generate CorrelationId
LOG_Builder.build().info('Queueing async job').emitAt('MyService.process');
System.enqueueJob(new MyAsyncProcessor(recordIds));  // Context captured in constructor

Batch Apex Pattern with Correlation

apex
public with sharing class MyBatchProcessor implements Database.Batchable<SObject>
{
	private String loggerContext;

	public MyBatchProcessor()
	{
		// Capture context when batch is created (before execution)
		this.loggerContext = LOG_Builder.serializeContext();
	}

	public Database.QueryLocator start(Database.BatchableContext context)
	{
		LOG_Builder.hydrateContext(loggerContext);
		LOG_Builder.build().info('Batch starting').emitAt('MyBatchProcessor.start');
		return QRY_Builder.selectFrom(Account.SObjectType).toQueryLocator();
	}

	public void execute(Database.BatchableContext context, List<Account> scope)
	{
		// MUST restore context in EACH execute - each is separate transaction
		LOG_Builder.hydrateContext(loggerContext);
		LOG_Builder.build().debug('Processing batch of ' + scope.size()).emitAt('MyBatchProcessor.execute');
		// ... processing ...
	}

	public void finish(Database.BatchableContext context)
	{
		LOG_Builder.hydrateContext(loggerContext);
		LOG_Builder.build().info('Batch complete').emitAt('MyBatchProcessor.finish');
	}
}

Key Correlation Methods

All methods are available on LOG_Builder:

MethodWhen to UseDescription
startCorrelation()Start of user action/API callGenerates new CorrelationId
serializeContext()Before enqueuing async jobReturns JSON string of current context
hydrateContext(String)Start of async job execute()Restores context from serialized string
setCorrelationId(String)When receiving external correlationSets specific ID from external system
setParentTransactionId(String)Manual parent linkingLinks to specific parent transaction

Capability Matrix (for Analysts)

This section is for analysts and admins deciding what they can set up without a developer. It lists the tasks that ship ready to schedule, the ones that need a small amount of custom code, and the configuration options for the built-in jobs.

What Can Be Scheduled?

Task TypeBuilt-in SolutionCustom RequiredEffort
Delete old recordsSCHED_PurgeRecordsNoConfigure only
Deactivate inactive usersSCHED_DeactivateUsersNoConfigure only
Process queued calloutsSCHED_PerformBatchedCalloutsNoConfigure only
Sync with external system-YesMedium
Generate reports-YesMedium
Send batch emails-YesLow
Data quality checks-YesMedium

Configuration Reference: SCHED_PurgeRecords

AttributeRequiredDefaultDescription
objectNameYes-API name of object to purge (e.g., LogEntry__c)
minimumNumberOfDaysNo90Records older than this are deleted
dateFieldNameNoCreatedDateField to check age against
batchSizeNo200Records per batch transaction
allOrNothingNofalseIf true, rollback batch on any error

Example Parameters__c value (set via DTO_NameValues):

json
{"objectName":"LogEntry__c","minimumNumberOfDays":"90","batchSize":"1000"}

Purging old chain records safely. AsyncChainExecution__c records build up as chains run, so purge them on a schedule the same way you would application logs, by pointing SCHED_PurgeRecords at the object. The setting that matters is the date field: purge by CompletedAt__c, not by the default CreatedDate.

json
{"objectName":"AsyncChainExecution__c","dateFieldName":"CompletedAt__c","minimumNumberOfDays":"30","batchSize":"200"}

CompletedAt__c is filled in only when a chain reaches an end state. A chain that is still Running, sitting Stalled while it waits for recovery, or waiting out a retry backoff has no completion timestamp yet. An age filter compares each record against a cut-off date, and a record whose date field is empty never falls inside that range, so the purge simply skips those in-flight chains and leaves them for the watchdog to recover. Purge by CreatedDate instead and you delete exactly those chains the moment they age past the window, taking with them the very records recovery depends on.

One gap to plan around: this configuration-only recipe has no status filter, so it treats a finished Failed chain the same as a completed one. Any chain that has reached an end state, successful or failed, is deleted once it is older than your window. If you keep failed chains to investigate later, size the retention window to cover how long you need that history, or export the failures you care about before they age out.

Configuration Reference: SCHED_DeactivateUsers

AttributeRequiredDefaultDescription
profileNamesYes-Pipe-separated profile names (e.g., `Standard User
minimumNumberOfDaysNo180Days since last login
batchSizeNo200Records per batch transaction
allOrNothingNofalseIf true, rollback batch on any error

Example Parameters__c value (set via DTO_NameValues):

json
{"profileNames":"Standard User|Chatter Free User","minimumNumberOfDays":"180","batchSize":"100"}

Monitoring and Troubleshooting

Monitoring Scheduled Jobs

Note: The following snippets use inline SOQL for Developer Console use. Production code should use SEL_ScheduledJob and QRY_Builder.

apex
// Query all active scheduled jobs from ScheduledJob__c
List<ScheduledJob__c> activeJobs = [
	SELECT SchedulerName__c, ClassName__c, CronExpression__c,
	       ScheduledJobId__c, Description__c, LastModifiedDate
	FROM ScheduledJob__c
	WHERE IsActive__c = true
	ORDER BY SchedulerName__c
];

// For each, get next fire time from CronTrigger
Set<Id> jobIds = new Set<Id>();
for(ScheduledJob__c job : activeJobs)
{
	if(String.isNotBlank(job.ScheduledJobId__c))
	{
		jobIds.add(job.ScheduledJobId__c);
	}
}

Map<Id, CronTrigger> triggerMap = new Map<Id, CronTrigger>([
	SELECT Id, NextFireTime, PreviousFireTime, State
	FROM CronTrigger
	WHERE Id IN :jobIds
]);

for(ScheduledJob__c job : activeJobs)
{
	CronTrigger trigger = triggerMap.get(job.ScheduledJobId__c);
	LOG_Builder.build().info(job.SchedulerName__c + ' - Next run: ' + trigger?.NextFireTime).emitAt('MonitorScheduledJobs');
}

Monitoring Async Job Execution

apex
// Query recent async job executions
List<AsyncApexJob> recentJobs = [
	SELECT Id, ApexClass.Name, Status, NumberOfErrors,
	       JobItemsProcessed, TotalJobItems, CreatedDate, CompletedDate
	FROM AsyncApexJob
	WHERE CreatedDate = TODAY
	AND JobType IN ('BatchApex', 'Queueable')
	ORDER BY CreatedDate DESC
	LIMIT 50
];

for(AsyncApexJob job : recentJobs)
{
	String progress = job.JobItemsProcessed + '/' + job.TotalJobItems;
	LOG_Builder.build().info(job.ApexClass.Name + ' [' + job.Status + '] ' + progress).emitAt('MonitorAsyncJobs');
}

// Find failed jobs
List<AsyncApexJob> failedJobs = [
	SELECT Id, ApexClass.Name, ExtendedStatus, NumberOfErrors
	FROM AsyncApexJob
	WHERE Status IN ('Failed', 'Aborted')
	AND CreatedDate = LAST_N_DAYS:7
];

Monitoring Async Chain Failures

Some Queueable failures cannot be caught from inside the step: a governor-limit crash that no try/catch can trap. Without help, such a crash would simply disappear. AsyncApexJob marks it Failed with almost no detail, and a hand-rolled chain could sit stuck in Running forever. The async chain framework closes that gap by attaching a Transaction Finalizer to every step. A finalizer runs with fresh governor limits even after the crash, and it does two things:

  1. Logs the failure. It writes an Error LogEntry__c carrying the chain's correlation ID and the reason, so the crash is kept and traceable even though the Queueable itself died (see the Logging Guide).
  2. Marks the chain Failed. It sets the AsyncChainExecution__c record's Status__c to Failed (with the reason in ErrorMessage__c), so the chain never lingers stuck in Running.

To find failed chains, filter AsyncChainExecution__c on Status__c = 'Failed' in a report, a list view, or the Developer Console:

apex
// Recent chain failures, newest first (Developer Console)
List<AsyncChainExecution__c> failedChains = [
	SELECT ChainName__c, CurrentStepName__c, ErrorMessage__c, CorrelationId__c, CompletedAt__c
	FROM AsyncChainExecution__c
	WHERE Status__c = 'Failed'
	AND CreatedDate = LAST_N_DAYS:7
	ORDER BY CreatedDate DESC
];

Use each row's CorrelationId__c to pull the full correlated trace from LogEntry__c. Or skip the query entirely: the Chain Monitor's detail panel has a View logs button that opens the Log Console already filtered to that chain's correlation ID, laying the whole run out as a timeline (see The Log Console in the Logging Guide). The chain Monitoring section above lists every AsyncChainExecution__c field, and the Logging Strategy table records the exact events the framework logs. If you already hold a single chain's Id, UTIL_AsyncChain.getStatus(executionId) returns its live status without running a query.

Expected entries in your debug logs

With Apex debug logging turned on, a running chain leaves a few System.TypeException entries in the log that are thrown and immediately caught inside the framework. These are expected and harmless. Salesforce records an exception in the debug log the instant it is thrown, even when the very next line catches it, so a caught exception the framework relies on still shows up. Knowing where each one comes from lets you tell them apart from a real fault:

  • Type detection. To read the concrete type name of a value, the framework uses a well-known Apex technique that deliberately provokes and then catches a System.TypeException. It runs once when a chain is built, once when the chain context is serialised, and once for every non-primitive value you read back through getAs(...). Each is a single, inexpensive caught exception, not an error.
  • A retried step. When a step is set to retry, each failed attempt logs its own exception. Seeing the same exception two or three times in a row while the chain stays healthy and still finishes is the retry doing its job, not one failure repeating uncontrollably.
  • One entry at the end. Every chain now writes a terminal LogEntry__c as it finishes, subject to your log threshold: INFO for a completion, ERROR for a failure, WARN for an abort (see Logging Strategy for how the threshold decides which ones you see).

None of these needs any change on your side.


Testing

You test async processors by wrapping the call in Test.startTest() / Test.stopTest(), which makes the async work run right away inside the test. The framework already tests its own Batch and Queueable machinery, so your tests can stay focused on one thing: proving that your Processable.execute() logic produces the result you expect.

Testing a processor with list-based input:

MyProcessor (defined earlier in this guide) casts its input to List<Account>, so the test seeds accounts to match. If your processor is typed for a different object, adjust the TST_Builder call and the QRY_Builder query to suit.

apex
@IsTest
private static void shouldProcessRecordsSuccessfully()
{
	List<Account> records = (List<Account>)TST_Builder.of(Account.SObjectType)
		.withCount(5)
		.buildList();

	Test.startTest();
	UTIL_AsynchronousJobLauncher.process(records, new MyProcessor());
	Test.stopTest();

	List<Account> results = QRY_Builder.selectFrom(Account.SObjectType)
		.fields(new List<SObjectField>{ Account.Description })
		.condition(Account.Id).isIn(new List<SObject>(records))
		.toList();
	Assert.areEqual(5, results.size(), 'All records should be processed');
}

Testing a configurable scheduler:

apex
@IsTest
private static void shouldExecuteScheduledJob()
{
	DTO_NameValues attributes = new DTO_NameValues();
	attributes.add('objectName', 'Foobar__c');
	attributes.add('batchSize', '200');

	SCHED_MyCustomJob job = new SCHED_MyCustomJob();
	job.setParameterValues(attributes);

	Test.startTest();
	job.execute(null);
	Test.stopTest();
}

Testing finalisation logic:

When your processor implements Finishable, Test.stopTest() runs the finish() method once all batches have completed. Assert on what finish() produced: status records created, emails sent through a mock, and so on.


Common Pitfalls

If a scheduled or async job isn't behaving, start here. Each row pairs a symptom with its usual cause and the fix:

IssueCauseSolution
Job doesn't startIsActive__c = falseSet IsActive__c = true
Job fails immediatelyInvalid class nameCheck ClassName__c includes namespace
Job runs but errorsProcessing logic issueCheck LogEntry__c for errors with job's CorrelationId
Job stuck in ProcessingApex error or timeoutCheck AsyncApexJob.ExtendedStatus, abort if needed
Schedule not updatingOld job not abortedFramework handles this - check trigger is active

Anti-Patterns

These are common mistakes (anti-patterns) that look reasonable but cause trouble later, with the better approach for each:

Anti-PatternWhy It's WrongInstead
Hardcoding System.enqueueJob() or Database.executeBatch()Bypasses automatic strategy selection and governor-limit-aware chunkingUse UTIL_AsynchronousJobLauncher.process()
Loading all records into memory before launchingCauses heap size exceptions on large datasetsPass a QRY_Builder.Builder so Batch Apex streams records
Business logic in the schedulable classCannot be reused, tested independently, or launched outside a scheduleImplement Processable for logic; keep schedulables thin
Ignoring Finishable for jobs that need cleanupNo notification, no error summary, no follow-up actionImplement Finishable for post-processing or alerting

Best Practices

For Developers

  1. Use AUTO strategy unless you have specific requirements
  2. Implement IF_Async.Finishable for notification/cleanup needs
  3. Use query-based processing for large datasets (>10,000 records)
  4. Size batches based on complexity - smaller for callouts, larger for simple DML
  5. Always capture logger context in async job constructors
  6. Test with Test.startTest()/Test.stopTest() to execute async synchronously

For Architects

  1. Prefer ScheduledJob__c over programmatic scheduling for production jobs
  2. Document job purposes in Description__c field
  3. Use built-in schedulers (SCHED_PurgeRecords, etc.) when possible
  4. Monitor job execution via reports on AsyncApexJob
  5. Plan for failure - implement error handling and alerting
  6. Consider callout limits when designing batch sizes for integrations

For Administrators

  1. Use ScheduledJob__c UI to manage scheduled jobs without code
  2. Check ScheduledJobId__c to verify job is actually scheduled
  3. Set IsActive__c = false to stop a job (don't delete unless permanent)
  4. Review CronExpression carefully - test in lower environments first
  5. Monitor with reports on AsyncApexJob for failures


Summary

ComponentPurposeWhen to Use
UTIL_AsynchronousJobLauncherLaunch ad-hoc async jobsTriggers, APIs, user actions
ScheduledJob__cDeclarative job schedulingRecurring jobs (recommended)
IF_Async.ProcessableDefine processing logicAll async processing
IF_Async.FinishableCleanup/notificationWhen post-processing needed
IF_SchedulableParameterized schedulersFlexible recurring jobs
SCHED_PurgeRecordsDelete old recordsData retention policies
SCHED_DeactivateUsersDeactivate inactive usersUser lifecycle management

Key Takeaways:

  1. Use the framework. Don't build custom Batch or Queueable jobs from scratch.
  2. Let AUTO decide. The framework picks the execution strategy for you.
  3. Schedule declaratively. Use ScheduledJob__c records for production jobs.
  4. Correlation is automatic in chains and the web service framework; wire it up by hand for custom async using LOG_Builder.
  5. Monitor proactively. Query AsyncApexJob for failures.