Skip to content

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 YourOrgAlias to 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.ClassName when 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
  1. How It Works
  2. Tier 1: See It Work (~2 minutes)
  3. Tier 2: Build Your Own (~15 minutes)
  4. Tier 3: Production Patterns (~5-10 minutes)
  5. Sensitive data is masked by default
  6. Common Issues
  7. What You Now Know
  8. Next Steps

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:

ComponentRole
kern.UTIL_AsyncChain.newChain('Name')Entry point: returns a ChainBuilder
kern.UTIL_AsyncChain.ChainStepAbstract base: extend it and implement work()
kern.UTIL_AsyncChain.ChainContextShared state between steps (get / put / has)
kern.UTIL_AsyncChain.StepResultReturn value: succeeded() / failed()
kern__AsyncChainExecution__cTracking 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:

apex
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):

apex
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:

text
Status: Completed
Completed: 1/1

See 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 start and sf apex run test commands.

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 it global lets it do that with no extra setup. If you prefer public 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.

apex
/**
 * @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:

bash
sf project deploy start -o YourOrgAlias -m "ApexClass:EnrichAccountStep"

Key patterns:

  • extends kern.UTIL_AsyncChain.ChainStep pulls in the work() contract
  • global override StepResult work(ChainContext) is the only required method
  • Read inputs via context.get(key), and store outputs via context.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:

apex
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:

apex
Account result = [SELECT Description FROM Account WHERE Name = 'Async Demo' ORDER BY CreatedDate DESC LIMIT 1];
System.debug('Description: ' + result.Description);

Expected output:

text
Description: Enriched by Fast Start chain

Step 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.

apex
/**
 * @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

bash
sf project deploy start -o YourOrgAlias -m "ApexClass:EnrichAccountStep_TEST"
sf apex run test -o YourOrgAlias -t EnrichAccountStep_TEST --code-coverage --synchronous --result-format human

Expected: 2 tests passing, 100% coverage on EnrichAccountStep.

Why Test.startTest() / Test.stopTest()? Without it, the background job (Queueable) that execute() 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):

apex
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:

apex
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:

apex
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):

apex
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

ProblemCauseFix
Failed (Class Not Found: MyStep)Step class is public, invisible across the package namespaceMake 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 indefinitelyFirst step threw an unhandled exception before persisting statusCheck 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 stepDefault maximumQueueableStackDepth is 1 in testsPass .withAsyncOptions(options) with maximumQueueableStackDepth raised
context.get() returns nullValue not JSON-serialisable, or key spelling mismatchStore only Ids, primitives, and simple collections; verify the key
Chain has already been executedReusing 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 a ChainBuilder
  • kern.UTIL_AsyncChain.ChainStep is the base you extend; implement work(ChainContext) and return kern.UTIL_AsyncChain.succeeded(...) or .failed(...) rather than throwing
  • ChainContext carries shared state across transactions via get / put / has; use getPreviousStepResult() in handler steps
  • kern__AsyncChainExecution__c is the tracking row (status, step counts, error message), kept and queryable from anywhere
  • kern.UTIL_AsyncChain.ApiStep runs a kern.API_Outbound handler as a chain step
  • global is required on step classes, because the framework creates them from their class name at runtime (reflection) across the package namespace boundary
  • Test.startTest() / Test.stopTest() forces the queued background job to run; raise AsyncOptions.maximumQueueableStackDepth for 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

Next Steps