Trigger Timing, Cross-Object Updates, and Asynchronous Escalation in Salesforce Apex

Share

Designing a great Apex trigger starts with three fundamentals:

  1. Choosing the right trigger timing (before vs. after)

  2. Managing cross-object updates cleanly

  3. Offloading heavy or error-prone work to asynchronous processes

Get these right, and your automations will stay fast, bulk-safe, and reliable — even under complex business logic.


? Trigger Timing: Before vs. After

Before Triggers

Use before triggers when you need to validate or modify records before they’re saved to the database.

At this point, the records don’t have IDs yet (for inserts), but you can still set default values, normalize data, and prevent bad records from saving.

Typical uses:

  • Setting default values

  • Data normalization or text cleanup

  • Field-level validation

  • Derived or denormalized fields

  • Formula-like calculations

After Triggers

Use after triggers once the records have been saved — meaning IDs exist and relationships are stable.

They’re ideal for creating related records, performing cross-object updates, or publishing Platform Events.

Important tip:
Avoid updating the same sObject inside an after trigger unless absolutely necessary — doing so can cause unwanted recursion.


? Cross-Object Updates (Safely and in Bulk)

When your logic needs to modify related records — for example, updating a parent Account when a Case changes — always bulkify your logic.

Key principles:

  • Collect all relevant parent or child IDs first.

  • Query those related records once.

  • Update each related record only once per transaction.

  • Group records by parent when possible to reduce locking conflicts.

Since after triggers have stable IDs, they’re generally the right place for cross-object DML. Keep your updates minimal — only touch fields that have changed.


? Asynchronous Escalation

Some operations — like sending alerts, making API callouts, or performing rollups — are best handled outside the main transaction.

By moving these to asynchronous jobs, you:

  • Avoid governor limits and mixed DML issues

  • Gain built-in retry capability

  • Improve transaction reliability and user experience

Recommended patterns:

  • Queueable Apex → for flexible async processing, with chaining and callout support

  • Platform Events → for fan-out behavior across subscribers (Apex, Flow, external apps)

  • @future (callout=true) → for simple or legacy use cases only


? Real-World Example

Scenario

When a Case is marked as High Priority or has an SLA breach flag, your automation should:

  1. Before Trigger: Normalize data and set a custom flag (Escalate__c).

  2. After Trigger:

    • Create a follow-up Task.

    • Update the parent Account (At_Risk__c = true) — one update per Account.

    • Enqueue a Queueable job to notify external systems (asynchronous escalation).


? One Small Trigger, Multiple Contexts

// CaseTrigger.trigger
trigger CaseTrigger on Case (before insert, before update, after insert, after update) {
    CaseTriggerHandler h = new CaseTriggerHandler();

    if (Trigger.isBefore && Trigger.isInsert) h.beforeInsert(Trigger.new);
    if (Trigger.isBefore && Trigger.isUpdate) h.beforeUpdate(Trigger.oldMap, Trigger.newMap);

    if (Trigger.isAfter && Trigger.isInsert) h.afterInsert(Trigger.new);
    if (Trigger.isAfter && Trigger.isUpdate) h.afterUpdate(Trigger.oldMap, Trigger.newMap);
}

? Handler — “Before” for Logic, “After” for DML and Async

// CaseTriggerHandler.cls
public with sharing class CaseTriggerHandler {
    // recursion guard per transaction
    private static Set<Id> escalatedOnce = new Set<Id>();

    public void beforeInsert(List<Case> newList) {
        for (Case c : newList) {
            normalize(c);
            c.Escalate__c = shouldEscalate(null, c); // old is null on insert
        }
    }

    public void beforeUpdate(Map<Id, Case> oldMap, Map<Id, Case> newMap) {
        for (Id id : newMap.keySet()) {
            Case oldC = oldMap.get(id), newC = newMap.get(id);
            normalize(newC);
            // compute-only; no cross-object DML here
            newC.Escalate__c = shouldEscalate(oldC, newC);
        }
    }

    public void afterInsert(List<Case> newList) {
        processEscalations(new List<Case>(newList));
    }

    public void afterUpdate(Map<Id, Case> oldMap, Map<Id, Case> newMap) {
        List<Case> changed = new List<Case>();
        for (Id id : newMap.keySet()) {
            Case oldC = oldMap.get(id), newC = newMap.get(id);
            // run only when Escalate__c turned true
            if (newC.Escalate__c == true && oldC.Escalate__c != true) {
                changed.add(newC);
            }
        }
        processEscalations(changed);
    }

    // ---------------- helpers ----------------
    private static void normalize(Case c) {
        if (c.Origin == null) c.Origin = 'Phone';
        if (c.Subject != null) c.Subject = c.Subject.trim();
    }

    private static Boolean shouldEscalate(Case oldC, Case newC) {
        Boolean highPriority = (newC.Priority == 'High' || newC.Priority == 'Critical');
        Boolean slaFlagged   = (newC.get('SLA_Breach_Flag__c') == true);
        Boolean statusNew    = (oldC == null) || (oldC.Status != 'Escalated' && newC.Status == 'Escalated');
        return highPriority || slaFlagged || statusNew;
    }

    private void processEscalations(List<Case> candidates) {
        if (candidates.isEmpty()) return;

        // Guard & collect
        Set<Id> caseIds = new Set<Id>();
        Set<Id> accountIds = new Set<Id>();
        List<Task> tasks = new List<Task>();

        for (Case c : candidates) {
            if (c.Id == null || escalatedOnce.contains(c.Id)) continue;
            escalatedOnce.add(c.Id);
            caseIds.add(c.Id);
            if (c.AccountId != null) accountIds.add(c.AccountId);

            tasks.add(new Task(
                WhatId   = c.Id,
                OwnerId  = c.OwnerId,
                Subject  = 'Escalation follow-up',
                Priority = 'High',
                Status   = 'Not Started'
            ));
        }

        if (!tasks.isEmpty()) insert tasks;

        // Cross-object: mark parent Accounts at risk (one update per Account)
        if (!accountIds.isEmpty()) {
            Map<Id, Account> parents = new Map<Id, Account>([
                SELECT Id, At_Risk__c FROM Account WHERE Id IN :accountIds
            ]);
            List<Account> updates = new List<Account>();
            for (Id aid : parents.keySet()) {
                Account upd = new Account(Id = aid, At_Risk__c = true);
                updates.add(upd);
            }
            if (!updates.isEmpty()) update updates;
        }

        // Async escalation: notify external system / post to event bus
        System.enqueueJob(new CaseEscalationJob(new List<Id>(caseIds)));
    }
}

⚙️ Asynchronous Escalation (Queueable Example)

// CaseEscalationJob.cls
public with sharing class CaseEscalationJob implements Queueable, Database.AllowsCallouts {
    private List<Id> caseIds;
    public CaseEscalationJob(List<Id> caseIds) { this.caseIds = caseIds; }

    public void execute(QueueableContext qc) {
        if (caseIds.isEmpty()) return;

        // Re-query lightweight fields for context
        List<Case> cases = [
            SELECT Id, CaseNumber, Priority, Owner.Email, Subject
            FROM Case WHERE Id IN :caseIds
        ];

        // Example 1: send internal notification (no callout)
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        mail.setToAddresses(new String[] { UserInfo.getUserEmail() });
        mail.setSubject('Case escalation batch: ' + cases.size() + ' case(s)');
        mail.setPlainTextBody('Examples: ' + cases.get(0).CaseNumber + ' ...');
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail }, false);

        // Example 2 (optional): HTTP callout to incident system
        // HttpRequest req = new HttpRequest();
        // req.setEndpoint('callout:IncidentBridge/escalate');
        // req.setMethod('POST');
        // req.setBody(JSON.serialize(cases));
        // new Http().send(req);
    }
}

? (Optional) Platform Event Fan-Out

// Case_Escalated__e is a Platform Event with CaseId__c (Text)
public with sharing class EscalationPublisher {
    public static void publish(List<Id> caseIds) {
        if (caseIds.isEmpty()) return;
        List<Case_Escalated__e> events = new List<Case_Escalated__e>();
        for (Id id : caseIds) {
            events.add(new Case_Escalated__e(CaseId__c = String.valueOf(id)));
        }
        Database.SaveResult[] sr = EventBus.publish(events);
    }
}

Trigger timing

⚡ Why This Works

  • Before triggers: Handle quick computations and data normalization without extra DML.

  • After triggers: Perform safe, bulkified cross-object operations like creating Tasks or updating Accounts.

  • Async processes: Handle heavy or fragile logic separately with Queueables or Platform Events, keeping your core trigger fast and safe.

 


? Final Thoughts

Use before triggers to prepare and validate your records before save, and after triggers for cross-object work that depends on record IDs.

Keep updates bulkified, minimal, and grouped by parent to reduce lock contention.
And finally, push expensive or error-prone logic to asynchronous Apex (Queueables or Platform Events) for cleaner architecture, stronger limits management, and easier troubleshooting.

That’s how you design triggers that scale — predictable, performant, and maintenance-friendly.

  • October 21, 2025