Fast Start - Async Processing
Framework: KernDX | Total time: ~25 minutes
What this is: A way to run a multi-step background job where each step runs in its own Salesforce transaction but they pass information to each other. Why it exists: Some work is too big or too slow to finish while a user waits, and a single background job can run out of Salesforce's per-transaction allowances (governor limits) or hit conflicts between calling an external system and saving records. Splitting the work into a sequence gives each step a fresh start, automatic recovery if one fails, and a record of how far it got, without you writing the wiring by hand. Who should follow this: developers building background processing, and the tech leads reviewing it. When to use it: any time you have several steps that should run after a user's request returns, and you want each step isolated and the whole run tracked. If you only need a single statement run in the background, the built-in kern.DML_Builder.async() is simpler; reach for a chain once there are multiple steps to sequence.
What you'll build in one line: a two-step background job that creates an Account, then enriches it in a second transaction.
Before you start:
- [ ] KernDX package installed in your org
- [ ] Org configured post-install (verify with the Kern app's Health Check, see Installation guide)
- [ ] CLI authenticated (
sf org open -o YourOrgAliasto verify), or just use the Developer Console (Gear Icon > Developer Console) for all Apex work - [ ] Working in a sandbox or scratch org (not production)
Subscriber orgs: Use
kern.ClassNamewhen extending framework classes (e.g.,kern.UTIL_AsyncChain.ChainStep). Your own classes don't need a namespace prefix. The framework's Type Resolver (how it finds the Apex classes in your namespace, so you tell it where to look) handles resolution automatically.
What you'll build: A two-step async chain (a sequence of background steps) that creates an Account, then enriches it in a second transaction, plus a paired test class with 100% coverage.
Success looks like: Your chain row appears in AsyncChainExecution__c with Status__c = Completed, the enriched Account shows the stamped Description, and the test class passes both branches.
In one line: kern.UTIL_AsyncChain.newChain('Foo').then(new MyStep()).execute(); runs your steps in order, tracks their status as a record, and recovers from errors, all built in.
Table of Contents
Expand
How It Works
A chain is just a list of steps that run one after another. Each step runs in its own background transaction (a Queueable), so it starts with a fresh set of Salesforce's per-transaction allowances (governor limits). A shared object called the ChainContext carries information from one step to the next.
Here are the pieces you'll work with:
| Component | Role |
|---|---|
kern.UTIL_AsyncChain.newChain('Name') | Entry point: returns a ChainBuilder |
kern.UTIL_AsyncChain.ChainStep | Abstract base: extend it and implement work() |
kern.UTIL_AsyncChain.ChainContext | Shared state between steps (get / put / has) |
kern.UTIL_AsyncChain.StepResult | Return value: succeeded() / failed() |
kern__AsyncChainExecution__c | Tracking row: status, step counts, error message, context data |
Why a chain instead of a single Queueable? Because each step runs in its own transaction, you get three things a single job can't give you. A step that calls an external system and a step that saves records no longer conflict. Each step starts with its allowances reset. And the framework writes progress and any errors to a record you can query later.
Tier 1: See It Work (~2 minutes)
Open Developer Console > Debug > Open Execute Anonymous Window. Build a one-step chain that stamps an Account's Description, then poll status:
Account record = new Account(Name = 'Async Demo');
insert record;
String executionId = kern.UTIL_AsyncChain.newChain('FastStartDemo')
.withInitialContext('accountId', record.Id)
.then(new EnrichStep())
.execute();
System.debug('Chain executionId: ' + executionId);
public class EnrichStep extends kern.UTIL_AsyncChain.ChainStep
{
public override kern.UTIL_AsyncChain.StepResult work(kern.UTIL_AsyncChain.ChainContext context)
{
Account toUpdate = new Account(Id = (Id)context.get('accountId'), Description = 'Enriched');
kern.DML_Builder.newTransaction().doUpdate(toUpdate).execute();
return kern.UTIL_AsyncChain.succeeded();
}
}Wait a few seconds, then check the status (replace PASTE_EXECUTION_ID_HERE):
Map<String, Object> status = kern.UTIL_AsyncChain.getStatus('PASTE_EXECUTION_ID_HERE');
System.debug('Status: ' + status.get('status'));
System.debug('Completed: ' + status.get('completedSteps') + '/' + status.get('totalSteps'));Expected output:
Status: Completed
Completed: 1/1See it in the org: App Launcher > Kern > AsyncChainExecution tab lists every chain with status, step counts, duration, and error message. This is the operator view.
If all you need is a single statement run in the background (no multiple steps to sequence), skip the chain entirely and use kern.DML_Builder.newTransaction().doUpdate(records).async().execute();. The Fast Start - DML covers this.
When to move to Tier 2: When you want a reusable step class with its own test coverage, a stable name in the AsyncChainExecution tab, and composability with other steps.
Tier 2: Build Your Own (~15 minutes)
No local project? You can create classes directly in the Developer Console (Gear Icon > Developer Console > File > New > Apex Class) and run tests from there too (Test > New Run). Paste the code, save, and skip the
sf project deploy startandsf apex run testcommands.
Step 1: Create the chain step
Build a step that reads an Account Id from the chain context and stamps a Description. Copy this code exactly as is into force-app/main/default/classes/EnrichAccountStep.cls:
Why
global? The framework finds your step class by name at runtime, and marking itgloballets it do that with no extra setup. If you preferpublic with sharing, you'll instead point the framework at your classes with a Type Resolver class (how it finds the Apex classes in your namespace: you tell it where to look). The Kern home page health check provides the code, or see Type Resolution.
/**
* @description Async chain step that enriches an Account by stamping a Description.
*
* @see EnrichAccountStep_TEST
*
* @author your.name@company.com
*
* @group Async Processing
*
* @date May 2026
*/
global inherited sharing class EnrichAccountStep extends kern.UTIL_AsyncChain.ChainStep
{
/** @description Chain context key that carries the Account Id to enrich. */
public static final String CONTEXT_KEY_ACCOUNT_ID = 'accountId';
/** @description Description value stamped on the enriched Account. */
@TestVisible
private static final String ENRICHMENT_NOTE = 'Enriched by Fast Start chain';
/** @description Error message returned when the context is missing the account Id. */
@TestVisible
private static final String ERROR_MISSING_ACCOUNT_ID = 'Missing accountId in chain context';
/**
* @description Reads the Account Id from context, updates the Description.
*
* @param context Shared chain context from upstream steps.
*
* @return StepResult — success with the enriched Id, or failure when accountId is missing.
*/
global override kern.UTIL_AsyncChain.StepResult work(kern.UTIL_AsyncChain.ChainContext context)
{
Id accountId = (Id)context.get(CONTEXT_KEY_ACCOUNT_ID);
if(accountId == null)
{
return kern.UTIL_AsyncChain.failed(ERROR_MISSING_ACCOUNT_ID);
}
Account record = new Account(Id = accountId, Description = ENRICHMENT_NOTE);
kern.DML_Builder.newTransaction().doUpdate(record).execute();
return kern.UTIL_AsyncChain.succeeded('Account enriched', accountId);
}
}Deploy:
sf project deploy start -o YourOrgAlias -m "ApexClass:EnrichAccountStep"Key patterns:
extends kern.UTIL_AsyncChain.ChainSteppulls in thework()contractglobal override StepResult work(ChainContext)is the only required method- Read inputs via
context.get(key), and store outputs viacontext.put(key, value) - Return
kern.UTIL_AsyncChain.succeeded(msg, data)or.failed(msg). Never throw - A public no-arg constructor is required so the framework can create the step by name (Apex provides one when you declare none)
Step 2: Execute the chain
Run from Execute Anonymous:
Account record = new Account(Name = 'Async Demo');
insert record;
String executionId = kern.UTIL_AsyncChain.newChain('AccountEnrichment')
.withInitialContext(EnrichAccountStep.CONTEXT_KEY_ACCOUNT_ID, record.Id)
.then(new EnrichAccountStep())
.execute();
System.debug('Chain executionId: ' + executionId);After a few seconds, query the result:
Account result = [SELECT Description FROM Account WHERE Name = 'Async Demo' ORDER BY CreatedDate DESC LIMIT 1];
System.debug('Description: ' + result.Description);Expected output:
Description: Enriched by Fast Start chainStep 3: Write the test class
Copy this code exactly as is into force-app/main/default/classes/EnrichAccountStep_TEST.cls. Both tests run the step through execute() rather than calling work() directly, because only the framework can create a kern.UTIL_AsyncChain.ChainContext, so you let it run the chain for you.
/**
* @description Tests for EnrichAccountStep.
*
* @see EnrichAccountStep
*
* @author your.name@company.com
*
* @group Async Processing
*
* @date May 2026
*/
@SuppressWarnings('PMD.ApexUnitTestClassShouldHaveRunAs')
@IsTest(SeeAllData=false IsParallel=true)
private class EnrichAccountStep_TEST
{
/** @description Chain name used by every test in this class. */
private static final String CHAIN_NAME = 'EnrichAccountChain';
/** @description Verifies the step stamps the Description when the context carries an Account Id. */
@IsTest
private static void shouldEnrichAccountWhenContextHasId()
{
Account record = (Account)kern.TST_Builder.of(Account.SObjectType)
.withOverride(Account.Name, 'Chain Demo').build();
Test.startTest();
kern.UTIL_AsyncChain.newChain(CHAIN_NAME)
.withInitialContext(EnrichAccountStep.CONTEXT_KEY_ACCOUNT_ID, record.Id)
.then(new EnrichAccountStep()).execute();
Test.stopTest();
Account result = (Account)kern.QRY_Builder.selectFrom(Account.SObjectType)
.fields(new List<SObjectField>{ Account.Description })
.condition(Account.Id).equals(record.Id).getFirst();
Assert.areEqual(EnrichAccountStep.ENRICHMENT_NOTE, result.Description, 'Description should be stamped');
}
/** @description Verifies the step fails when the context does not carry an Account Id. */
@IsTest
private static void shouldFailWhenContextMissingAccountId()
{
Test.startTest();
String executionId = kern.UTIL_AsyncChain.newChain(CHAIN_NAME)
.then(new EnrichAccountStep()).execute();
Test.stopTest();
Map<String, Object> status = kern.UTIL_AsyncChain.getStatus(executionId);
Assert.areEqual('Failed', (String)status.get('status'), 'Chain should be Failed');
Assert.areEqual(EnrichAccountStep.ERROR_MISSING_ACCOUNT_ID, (String)status.get('errorMessage'), 'Error propagated');
}
}Step 4: Deploy and verify
sf project deploy start -o YourOrgAlias -m "ApexClass:EnrichAccountStep_TEST"
sf apex run test -o YourOrgAlias -t EnrichAccountStep_TEST --code-coverage --synchronous --result-format humanExpected: 2 tests passing, 100% coverage on EnrichAccountStep.
Why
Test.startTest() / Test.stopTest()? Without it, the background job (Queueable) thatexecute()enqueues never actually runs inside the test.Test.stopTest()forces that queued work to run right then. Forgetting this is the single most common chain-test bug.
Tier 3: Production Patterns (~5-10 minutes)
Handlers: attach steps that run when the chain fails or finishes, so you can react to either outcome. Inside onError, context.getPreviousStepResult() returns the failed step's StepResult (inspect message / error):
kern.UTIL_AsyncChain.newChain('AccountEnrichment')
.withInitialContext('accountId', record.Id)
.then(new EnrichAccountStep())
.onError(new NotifyAdminStep())
.onComplete(new EmitMetricStep())
.execute();Continue past optional failures: pass true to .then() to let the chain skip over a failed step and keep going:
kern.UTIL_AsyncChain.newChain('OrderProcessing')
.then(new ChargePaymentStep())
.then(new SendReceiptStep(), true) // failure here does not stop the chain
.then(new MarkOrderShippedStep())
.execute();Wrap an outbound API call as a step: kern.UTIL_AsyncChain.ApiStep runs any kern.API_Outbound handler for you, handling validation, the callout, parsing the response, saving records, and ApiCall__c logging:
kern.UTIL_AsyncChain.newChain('OrderConfirmation')
.withInitialContext('orderId', order.Id)
.then(new kern.UTIL_AsyncChain.ApiStep(API_ChargePayment.class)
.triggeringRecordFrom('orderId')
.withParameter('amount', '99.99'))
.execute();See Fast Start - Outbound APIs for the handler shape.
Test chains of more than one step: in a test, Apex only lets Test.stopTest() run one background job by default. Raise that limit with AsyncOptions (the platform built-in System.AsyncOptions, not a kern inner class):
AsyncOptions options = new AsyncOptions();
options.maximumQueueableStackDepth = 5;
kern.UTIL_AsyncChain.newChain('MultiStep')
.then(new StepOne()).then(new StepTwo()).then(new StepThree())
.withAsyncOptions(options)
.execute();Re-run a bulk step safely: if a step loops over many records, derive a per-record key with context.idempotencyKey(record.Id) and store it on the external-id field you upsert against. A replay after a partial failure then only reprocesses the rows that did not finish the first time. See Safe to run twice (idempotency) in the guide for the step-level and custom-grain forms too.
See the Async Processing Guide for delayed start, retry strategies, finalizer recovery.
Sensitive data is masked by default
Anything the chain saves to its tracking row (AsyncChainExecution__c) is scrubbed of sensitive values before it is stored, so secrets don't end up sitting in a queryable record. The chain's context data, step logs, and error messages all pass through the data masking framework first. Out of the box, MaskSecretKeys blanks out common secret JSON keys (password, token, apiKey, etc.) and MaskPaymentCard blanks out card numbers that pass the standard card-number check (Luhn).
There is one consequence to know about. Putting a secret in the context, such as context.put('password', userPassword), is safe once stored, but the scrubbing is permanent: a later step that re-reads the saved row sees only the redacted value, not the original. So don't rely on the chain context to pass credentials between steps for an authenticated callout. Use a Named Credential instead.
Common Issues
| Problem | Cause | Fix |
|---|---|---|
Failed (Class Not Found: MyStep) | Step class is public, invisible across the package namespace | Make the step class global, or register a Type Resolver |
| Test assertions fail (Description never stamped) | Missing Test.startTest() / Test.stopTest() around execute() | Wrap the chain build + execute in Test.startTest/stopTest |
Status: Running indefinitely | First step threw an unhandled exception before persisting status | Check the kern__LogEntry__c rows for the chain's correlationId. A scheduled Chain Watchdog also marks such runs Stalled and re-drives the safe ones; the Health Check schedules it in one click |
| Chain test only runs first step | Default maximumQueueableStackDepth is 1 in tests | Pass .withAsyncOptions(options) with maximumQueueableStackDepth raised |
context.get() returns null | Value not JSON-serialisable, or key spelling mismatch | Store only Ids, primitives, and simple collections; verify the key |
Chain has already been executed | Reusing a ChainBuilder after .execute() | Build a fresh chain via kern.UTIL_AsyncChain.newChain(...) |
What You Now Know
kern.UTIL_AsyncChain.newChain(name)is the entry point that returns aChainBuilderkern.UTIL_AsyncChain.ChainStepis the base you extend; implementwork(ChainContext)and returnkern.UTIL_AsyncChain.succeeded(...)or.failed(...)rather than throwingChainContextcarries shared state across transactions viaget/put/has; usegetPreviousStepResult()in handler stepskern__AsyncChainExecution__cis the tracking row (status, step counts, error message), kept and queryable from anywherekern.UTIL_AsyncChain.ApiStepruns akern.API_Outboundhandler as a chain stepglobalis required on step classes, because the framework creates them from their class name at runtime (reflection) across the package namespace boundaryTest.startTest() / Test.stopTest()forces the queued background job to run; raiseAsyncOptions.maximumQueueableStackDepthfor chains of more than one step under test- Use
kern.DML_Builder.async()for single-statement background work; use a chain only when you need several steps in sequence, each with its allowances reset