DEV Community

Rohit Maharashi
Rohit Maharashi

Posted on

Salesforce Apex Async Trigger Job Orchestration Framework

Overview

Salesforce triggers often require post-commit or heavy logic that exceeds synchronous limits. Queueable Apex is the preferred async solution, but Salesforce restricts you to one queueable enqueue per async context. This makes orchestrating complex, multi-step, or nested async logic challenging.

This framework solves those challenges, letting you:

  • Run multiple jobs in a strict order (even with sub-chains)
  • Support internal chaining (nested async flows)
  • Control job execution order and enablement via metadata (CMDT)
  • Reuse the pattern across any object or trigger
  • Stay governor-safe and testable

Architecture at a Glance

Trigger
  ↓
Trigger Handler
  ↓
AsyncWorkCollector.add(...)
  ↓
AsyncWorkCollector.flush()
  ↓
AsyncTriggerJobService.chainNextJob (entry point)
  ↓
  [JobA] → [JobB] → [JobC] → [JobC1] → [JobC2] → [JobD] → [JobE]
  (with support for sub-chains, e.g., C1→C2 as an internal chain)
Enter fullscreen mode Exit fullscreen mode

1. Enum: AsyncTriggerJobType

Defines all possible jobs in your orchestration, including sub-chain jobs.

public enum AsyncTriggerJobType {
    A, B, C, C1, C2, D, E
    // Add more as needed
}
Enter fullscreen mode Exit fullscreen mode

2. Custom Metadata (CMDT) Setup

Create a CMDT named Async_Trigger_Job_Configuration__mdt with fields:

  • Name: (matches enum value, e.g., C1)
  • Sequence__c: (integer, controls order)
  • Is_Enabled__c: (checkbox)
  • ObjectGroup__c: (e.g., Order)

You need one record per job type. Example:

(Name, Sequence, Is_Enabled, ObjectGroup) -> (A, 1, TRUE, Order)

3. Service: AsyncJobTypeToggleService

Handles enablement and ordering using CMDT.

public class AsyncJobTypeToggleService {
    // Checks if a specific job type is enabled (for a given object group)
    public static Boolean isEnabled(
        AsyncTriggerJobType type,
        Map<String, Async_Trigger_Job_Configuration__mdt> configs,
        String objectGroup
    ) {
        Async_Trigger_Job_Configuration__mdt config = configs.get(type.name());
        return config != null && config.Is_Enabled__c && config.Object_Group__c == objectGroup;
    }
    // Returns a list of enabled job types, filtered by object group and ordered by sequence
    public static List<AsyncTriggerJobType> getOrderedEnabledTypes(
        Map<String, Async_Trigger_Job_Configuration__mdt> configs,
        Set<AsyncTriggerJobType> candidates,
        String objectGroup
    ) {
        List<AsyncTriggerJobType> enabled = new List<AsyncTriggerJobType>();
        for (AsyncTriggerJobType type : candidates) {
            Async_Trigger_Job_Configuration__mdt rec = configs.get(type.name());
            if (rec != null && rec.Is_Enabled__c && rec.Object_Group__c == objectGroup) {
                enabled.add(type);
            }
        }
        enabled.sort(new AsyncJobTypeMetaComparator(configs));
        return enabled;
    }
    // Comparator for sorting by Sequence__c
    public class AsyncJobTypeMetaComparator implements Comparator<AsyncTriggerJobType> {
        private Map<String, Async_Trigger_Job_Configuration__mdt> configurations;
        public AsyncJobTypeMetaComparator(Map<String, Async_Trigger_Job_Configuration__mdt> configurations) {
            this.configurations = configurations;
        }
        public Integer compare(AsyncTriggerJobType typeA, AsyncTriggerJobType typeB) {
            Async_Trigger_Job_Configuration__mdt recA = configurations.get(typeA.name());
            Async_Trigger_Job_Configuration__mdt recB = configurations.get(typeB.name());
            Integer seqA = recA != null ? Integer.valueOf(recA.Sequence__c) : null;
            Integer seqB = recB != null ? Integer.valueOf(recB.Sequence__c) : null;
            // If seqA is null, place it after seqB
            if (seqA == null) {
                return 1;
            }
            // If seqB is null, place seqA before seqB
            if (seqB == null) {
                return -1;
            }
            // Compare sequence values
            if (seqA < seqB) {
                return -1;
            } else if (seqA > seqB) {
                return 1;
            } else {
                return 0;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Data Container: AsyncTriggerJobBundle

Tracks the jobs, their order, and supports internal chaining.

public with sharing class AsyncTriggerJobBundle {
    private Map<AsyncTriggerJobType, Object> jobMap;
    private List<AsyncTriggerJobType> orderedTypes;
    private Integer currentIndex = 0;
    private AsyncTriggerJobBundle continuationBundle;
    public AsyncTriggerJobBundle(Map<AsyncTriggerJobType, Object> jobMap, String objectGroup) {
        this.jobMap = new Map<AsyncTriggerJobType, Object>(jobMap);
        Map<String, Async_Trigger_Job_Configuration__mdt> configs = Async_Trigger_Job_Configuration__mdt.getAll();
        this.orderedTypes = AsyncJobTypeToggleService.getOrderedEnabledTypes(
            configs,
            new Set<AsyncTriggerJobType>(jobMap.keySet()),
            objectGroup
        );
        this.currentIndex = 0;
    }
    public void setContinuationBundle(AsyncTriggerJobBundle bundle) { this.continuationBundle = bundle; }
    public AsyncTriggerJobBundle getContinuationBundle() { return this.continuationBundle; }
    public Boolean hasMoreJobs() { return jobMap != null && currentIndex < orderedTypes.size(); }
    public AsyncTriggerJobType getNextJobType() { if (!hasMoreJobs()) return null; return orderedTypes[currentIndex++]; }
    public Object getPayloadFor(AsyncTriggerJobType type) { return jobMap.get(type); }
}
Enter fullscreen mode Exit fullscreen mode

5. Collector: AsyncWorkCollector

Gathers jobs during the trigger and starts the async chain.

public class AsyncWorkCollector {
    private static Map<AsyncTriggerJobType, Object> jobRegistry = new Map<AsyncTriggerJobType, Object>();
    private static Boolean flushed = false;
    public static void add(AsyncTriggerJobType jobType, Object payload, String objectGroup) {
        // ...enablement check...
        jobRegistry.put(jobType, payload);
    }
    public static void flush(String objectGroup) {
        if (flushed || jobRegistry.isEmpty()) return;
        flushed = true;
        AsyncTriggerJobBundle bundle = new AsyncTriggerJobBundle(jobRegistry, objectGroup);
        AsyncTriggerJobService.chainNextJob(bundle, objectGroup);
    }
    public static void reset() {
        jobRegistry.clear();
        flushed = false;
    }
}
Enter fullscreen mode Exit fullscreen mode

6. Chaining & Orchestration: AsyncTriggerJobService

Encapsulates chaining logic and internal sub-chain orchestration.

public class AsyncTriggerJobService {
    public static void chainNextJob(AsyncTriggerJobBundle bundle, String objectGroup) {
        if (bundle != null && bundle.hasMoreJobs()) {
            AsyncTriggerJobType nextType = bundle.getNextJobType();
            Object nextPayload = bundle.getPayloadFor(nextType);
            Queueable nextJob = AsyncTriggerJobFactory.createJob(nextType, nextPayload, bundle, objectGroup);
            if (nextJob != null) {
                System.enqueueJob(nextJob);
            }
        } else if (bundle != null && bundle.getContinuationBundle() != null) {
            chainNextJob(bundle.getContinuationBundle(), objectGroup);
        }
    }
    public static void startInternalChain(
        Map<AsyncTriggerJobType, Object> subJobs,
        String objectGroup,
        AsyncTriggerJobBundle mainChainBundle
    ) {
        AsyncTriggerJobBundle subBundle = new AsyncTriggerJobBundle(subJobs, objectGroup);
        subBundle.setContinuationBundle(mainChainBundle);
        chainNextJob(subBundle, objectGroup);
    }
}
Enter fullscreen mode Exit fullscreen mode

7. Job Factory

AsyncTriggerJobFactory:

public class AsyncTriggerJobFactory {
    public static Queueable createJob(
        AsyncTriggerJobType type,
        Object payload,
        AsyncTriggerJobBundle bundle,
        String objectGroup
    ) {
        if (objectGroup == 'Order') {
            return OrderAsyncJobFactory.create(type, payload, bundle);
        }
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderAsyncJobFactory:

public class OrderAsyncJobFactory {
    public static Queueable create(
        AsyncTriggerJobType type,
        Object payload,
        AsyncTriggerJobBundle bundle
    ) {
        String typeName = type.name();
        if (typeName == 'A') return new JobAQueueable(payload, bundle);
        if (typeName == 'B') return new JobBQueueable(payload, bundle);
        if (typeName == 'C') return new JobCQueueable(payload, bundle);
        if (typeName == 'C1') return new JobC1Queueable(payload, bundle);
        if (typeName == 'C2') return new JobC2Queueable(payload, bundle);
        if (typeName == 'D') return new JobDQueueable(payload, bundle);
        if (typeName == 'E') return new JobEQueueable(payload, bundle);
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

8. Example Job Classes

JobCQueueable (starts a sub-chain):

public class JobCQueueable implements Queueable {
    private Object payload;
    private AsyncTriggerJobBundle jobBundle;
    public JobCQueueable(Object payload, AsyncTriggerJobBundle bundle) {
        this.payload = payload;
        this.jobBundle = bundle;
    }
    public void execute(QueueableContext context) {
        Boolean internalChained = false;
        // ... C's work ...
        Map<AsyncTriggerJobType, Object> subJobs = new Map<AsyncTriggerJobType, Object>{
            AsyncTriggerJobType.C1 => null,
            AsyncTriggerJobType.C2 => null
        };
        internalChained = true;
        AsyncTriggerJobService.startInternalChain(
            subJobs,
            'Order',
            this.jobBundle
        );
        // Do NOT call chainNextJob here; sub-chain will resume main chain!
        if(!internalChained) {
            AsyncTriggerJobService.chainNextJob(jobBundle, 'Order');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

JobC1Queueable and JobC2Queueable:

public class JobC1Queueable implements Queuable{
    private Object payload;
    private AsyncTriggerJobBundle jobBundle;
    public JobC1Queueable(Object payload, AsyncTriggerJobBundle bundle) {
        this.payload = payload;
        this.jobBundle = bundle;
    }
    public void execute(QueueableContext context) {
        // ... C1's work ...
        AsyncTriggerJobService.chainNextJob(jobBundle, 'Order');
    }
}
Enter fullscreen mode Exit fullscreen mode

(Same pattern for JobC2Queueable.)

9. How Internal Chaining Works

JobCQueueable creates a sub-bundle for C1 and C2, sets its continuation to the main bundle, and enqueues the dispatcher for the sub-bundle.

The dispatcher runs C1, then C2.

After C2, the dispatcher sees the sub-bundle is done, checks for a continuation bundle, and resumes the main chain (D, E, etc).

10. Trigger Handler Example

public class OrderTriggerHandler {
    public static void afterInsert(List<Order> orders) {
        AsyncWorkCollector.add(AsyncTriggerJobType.A, orders);
        AsyncWorkCollector.add(AsyncTriggerJobType.B, orders);
        AsyncWorkCollector.add(AsyncTriggerJobType.C, orders);
        // ... add more as needed
        AsyncWorkCollector.flush('Order');
    }
}
Enter fullscreen mode Exit fullscreen mode

Summary

  • Declarative: Admins can enable/disable jobs and set order in metadata.
  • Governor-safe: Only one queueable enqueued per async context.
  • Supports internal chaining: Sub-chains can run before resuming the main chain.
  • Reusable: Works for any object or trigger.
  • Testable: Each job is isolated and easy to test.

Top comments (0)