Advanced Salesforce Trigger Architecture and Order of Execution Guide

Salesforce order of execution is the platform’s deterministic save pipeline for an insert, update, or upsert request, but that pipeline has important exceptions, versioned behaviors, and “side-loop” branches that often matter more than the mainline path. In the current Apex Developer Guide, the canonical save sequence begins with loading old and new values, runs before-save record-triggered flows before Apex before triggers, then re-runs system/custom validation, duplicate rules, the database save, after triggers, assignment and auto-response rules, workflow rules, escalation rules, legacy Process Builder / workflow-launched flows, after-save record-triggered flows, entitlement rules, roll-up / cross-object recalculations, criteria-based sharing, commit, and finally post-commit logic such as email, future/queueable jobs, and asynchronous flow paths. Critically, workflow field updates can cause one extra before/after update trigger pass, and during a recursive save Salesforce skips steps from assignment rules through grandparent roll-up processing.

For architecture, the safest modern pattern is a single trigger per object that delegates to a handler/service layer, treats triggers as orchestration points rather than business-logic containers, keeps same-record mutation in before contexts whenever possible, makes cross-object work idempotent, and pushes slow or integration-heavy work to queueable or event-driven subscribers. Salesforce does not guarantee the firing order of multiple triggers on the same object/event, and those triggers share the same governor budget. Record-triggered flows do have controllable relative ordering for the same object and same trigger type: values 1–1000 run first, unordered flows run next by created date, and 1001–2000 run last.

The most expensive production defects usually come from widely missed edge cases rather than from the main save path itself: workflow field-update re-entry, partial-success DML causing repeated trigger invocations in the same transaction, stale assumptions about Trigger.old after workflow updates, child triggers not firing for cascade delete or merge reparenting, mixed DML between setup and non-setup objects, callouts attempted with pending DML or unreleased savepoints, and formula-field assumptions that ignore the fact that formula values are computed at read time rather than stored. Platform events and Change Data Capture add more nuance: both are asynchronous/event-bus mechanisms, platform-event triggers process messages sequentially by replay ID, and change event triggers run asynchronously after database completion, which makes them excellent for decoupling but poor substitutes for same-transaction invariants.

Unless otherwise noted, this report assumes current official behavior in the latest publicly available Salesforce developer documentation as of July 2026. Where behavior is versioned, that caveat is called out explicitly. One important example: in API version 53.0 and earlier, after-save record-triggered flows run after entitlements, whereas current documentation places after-save record-triggered flows before entitlement rules.

Definitions and mental models

A trigger is Apex that runs before or after record events such as insert, update, delete, undelete, merge, and upsert-related operations. Before triggers are intended to update or validate fields on the same record before save; after triggers are intended for actions that need a persisted record identifier or require work beyond same-record mutation. In trigger context, Trigger.new records are modifiable only in before triggers.

Order of execution is the server-side sequence Salesforce uses when a record save request is processed. It is not merely “before trigger then after trigger.” It includes system validation, flows, duplicate rules, assignment / auto-response / escalation / entitlement logic, workflow rules, legacy process automation, roll-up recalculation, sharing evaluation, and post-commit work. The official current reference is the “Triggers and Order of Execution” section of the Apex Developer Guide, and Salesforce explicitly warns that the Data Model Gallery flowchart can lag behind the latest API-version docs.

A transaction is the entire unit of work that either commits or rolls back. Most trigger-time logic is transaction-scoped, meaning all automation in that transaction shares limits and rollback semantics. Post-commit work, such as future/queueable jobs and asynchronous flow paths, is outside the original save transaction and therefore sees committed state only.

An idempotent trigger design means that if the same logical record change is processed twice, the second pass does not create duplicate side effects. In Salesforce this matters because workflow field updates can re-enter update triggers once more, partial-success DML may refire triggers in the same transaction, and event-driven subscribers can observe retries or repeated messages. Official docs specifically note both the extra workflow-trigger pass and the static-variable persistence across partial-success attempts.

A setup object is an sObject that affects user access or org configuration, such as User, PermissionSetAssignment, UserRole, and related security/setup objects. Mixed DML restrictions exist because Salesforce prevents setup-object DML and ordinary data-object DML from occurring together in the same transaction in many cases. Salesforce’s official workaround patterns are to separate the work into another transaction, such as @future, queueable, or test-time System.runAs.

A formula field is not stored in the database and is computed dynamically when requested. That matters for trigger design because code can read formula values, but a “formula-only change” is not itself a DML write. Salesforce also now exposes Formula.recalculateFormulas(...), but formula handling still has edge cases, particularly in before-context designs and in CDC, where formula fields are not supported in change event triggers.

Official order of execution

The current official save pipeline for insert, update, and upsert is summarized below from the Apex Developer Guide. The table keeps Salesforce’s step numbering because that numbering is often referenced in debugging conversations and certification/interview discussions.

Official stepWhat happensImportant nuance
1Load old record from DB, or initialize for upsertUpsert starts by loading old state if matched, or initializing new state if not.
2Load new field values from requestValidation differs by source: standard UI, multiline item creation, Apex/API, and Salesforce checks custom foreign keys for self-reference before triggers.
3Run before-save record-triggered flowsCurrent docs place before-save flows before Apex before triggers.
4Run all before triggersBest place for same-record field mutation and addError() on the current row.
5Re-run most system validation and custom validation rulesLayout-specific rules are not re-enforced on the second pass for standard UI edit pages.
6Execute duplicate rulesIf a duplicate rule blocks, the record is not saved and after-trigger/workflow steps do not occur.
7Save record to DB, not yet committedDatabase-generated IDs now exist for inserts, but rollback is still possible.
8Run all after triggersSame-row Trigger.new is read-only here. Use separate DML only if truly necessary.
9Assignment rulesApplies when relevant rules exist; assignment rules are for leads and cases.
10Auto-response rulesApplies to leads and cases when configured.
11Workflow rulesIf workflow field updates happen, Salesforce updates the record again, reruns system validations, and fires before/after update triggers one more time and only one more time. It does not rerun custom validation rules, flows, duplicate rules, Process Builder, or escalation rules on that workflow re-save.
12Escalation rulesThis mainly matters for cases.
13Legacy Flow automationsCurrent docs list Process Builder and workflow-launched flows here, not in guaranteed order. If they perform DML, the affected record goes through a save procedure again.
14After-save record-triggered flowsCurrent docs place these before entitlement rules. In API 53.0 and earlier they ran after entitlements.
15Entitlement rulesCurrent location in the sequence for modern API versions.
16Parent roll-up summary / cross-object workflow recalculationParent goes through save procedure if recalculated/updated.
17Grandparent roll-up summary / cross-object workflow recalculationGrandparent also goes through save procedure if applicable.
18Criteria-based sharing evaluationHappens before commit, but late in the transaction.
19CommitDurable write point.
20Post-commit logicExamples include email, enqueued future/queueable jobs, and asynchronous paths in record-triggered flows.

Two official nuances deserve to be memorized because they explain many “but why didn’t X run?” incidents. First, during a recursive save Salesforce skips steps 9 through 17, which means assignment, auto-response, workflow, escalation, legacy process automation, after-save flows, entitlements, and parent/grandparent roll-up phases are not replayed in the same way during recursion. Second, if more than one trigger exists on the same object and event, firing order is not guaranteed.

The modern flow-order story is more nuanced than the legacy “flows are unordered” advice. For record-triggered flows on the same object and same trigger type, Salesforce lets you define a trigger order value from 1 to 2,000. Flows ordered 1–1000 run first in ascending order, unordered flows run next in created-date order, and 1001–2000 run after that. This ordering only applies among flows with the same object and same trigger type, and it still respects the broader platform order of execution.

The diagram below is a practical, current-state mental model of the save transaction.

That diagram reflects the current official guide, including the workflow-field-update re-entry path and post-commit placement of asynchronous work.

High-risk scenarios and edge cases

Bulk triggers and governor behavior

Triggers are always bulk by nature, even when the UI appears to save a single row. The quick reference lists the familiar per-transaction ceilings: 100 SOQL queries in synchronous Apex and 200 in asynchronous Apex, 50,000 queried rows, 150 DML statements, 10,000 rows processed by DML, 100 callouts, 120 seconds cumulative callout timeout, 50 future-method invocations per Apex invocation, and recursion stack depth 16 for trigger-spawned insert/update/delete recursion. Batch Apex resets per-transaction limits for each execute chunk.

That means poor trigger architecture fails in two ways at scale: it can exceed limits in the top-level transaction, and it can also consume budget needed by downstream automation in the same transaction. Multiple triggers on one object/event worsen this because they share one governor budget, while Salesforce still doesn’t guarantee their order.

A practical corollary is that bulkification is not just “avoid SOQL in loops.” It also means: aggregate queries per object graph, collapse repeated DML, avoid trigger-to-trigger ping-pong, and design cross-object logic so that it updates the minimal number of records necessary. The official docs explicitly note that when partial-success DML is used, triggers can fire again during subsequent attempts in the same transaction and static class variables do not reset. A simplistic static Boolean recursion flag can therefore suppress valid later work unexpectedly.

Recursion, re-entry, and idempotency

The most misunderstood recursion source in Salesforce is not explicit self-DML. It is secondary save entry caused by platform automation. Workflow field updates trigger another update pass once more, and only once more, but many teams forget that this “free” second pass still re-executes before/after update triggers. Meanwhile, the same docs say custom validation rules, flows, duplicate rules, Process Builder, and escalation rules do not rerun in that workflow-induced replay. That asymmetry is the reason a trigger sometimes seems to fire “too many times” while a flow does not.

The safest trigger patterns therefore guard by record state transition, not by a global “ran already” flag. For example, update a parent only when a child crosses a business threshold that was previously false, or enqueue downstream work only when a durable marker field changes from null to populated. State-based idempotency holds up under workflow re-entry, roll-up recalc, duplicate delivery, and async retries much better than one-shot static flags. This is also the same strategy Salesforce recommends implicitly in CDC guidance, where updating the same source object from its change event trigger can recurse indefinitely unless updates are limited so they do not happen every time the trigger refires.

Before versus after, same-record versus related-record work

The cleanest rule is simple: if you are only changing fields on the triggering row, prefer before-save automation. Before triggers and before-save flows let you mutate the current row without extra DML; after triggers require separate DML for same-row changes because trigger records are read-only there. Salesforce’s own flow guidance says use before-save record-triggered flow when you want to update or validate the triggering record before database save, and use after-save flow when you need anything beyond that, such as related-record DML, email, or invoking other automation.

A concise comparison is below.

PatternBest useMain advantageMain risk
Before triggerSame-record field derivation, validation, addError()No extra DML for current row; earliest Apex hook after before-save flowNo record Id yet on insert; relationship side effects often premature.
After triggerRelated-record work, logic requiring committed row identityRecord Id exists; can create/update related dataTrigger.new is read-only; same-row update requires extra DML and recursion control.
Before-save flowSimple same-row updates/validationEarliest declarative same-row hook; runs before before triggersSame-row only; ordering only among same trigger-type flows.
After-save flowDeclarative related-record work, notifications, async pathStrong admin-first option for post-save automationCan create complex re-entry paths if it updates the same object or related parents.

Workflow rules, Process Builder, and record-triggered flows

The official OOE still includes legacy automations because many production orgs still have them. The danger is not just complexity; it is split ordering semantics. Workflow rules run first, and only workflow field updates get the special “extra update pass” behavior. Process Builder and workflow-launched flows run later in a not-guaranteed order, and any DML they perform starts another save procedure. Record-triggered flows have their own, more controllable placement and ordering rules.

That means a single object can have three distinct declarative automation timing models at once:

Architecturally, that is a strong argument for flattening legacy automation where possible and converging on record-triggered flows plus Apex services when code is necessary. Salesforce’s architecture guidance favors understanding and intentionally designing around built-in order-of-execution and transaction behavior, rather than allowing multiple automation types to evolve independently.

Mixed DML

Mixed DML restrictions exist because setup-object changes can alter access and sharing semantics mid-transaction. Salesforce’s official docs list many setup objects that can’t be mixed with non-setup DML in the same transaction, including User, UserRole, PermissionSetAssignment, GroupMember, and others. The docs explicitly recommend splitting such operations into separate transactions, and they show an @future example where data-object DML occurs in the original transaction while user creation occurs asynchronously. In tests, mixed DML becomes legal when enclosed correctly in System.runAs blocks, or when one side happens asynchronously.

A high-value nuance from the official mixed-DML page is that the list is not exhaustive. Teams should therefore think in categories, not just in memorized object names: if an object changes users, permissions, role hierarchy, territory/security assignments, or setup-level access, treat it as mixed-DML sensitive.

Parent/child interactions, roll-ups, cross-object updates, and sharing

When a child save affects a parent roll-up summary field or cross-object workflow, Salesforce performs the parent calculation/update and the parent record goes through a save procedure. If that parent then affects a grandparent roll-up or cross-object workflow, the grandparent also goes through a save procedure. Criteria-based sharing evaluation happens only later, after those recalculations and before commit. This is why child changes can indirectly cause parent and grandparent automation even when your original DML touched only the child.

The converse is also critical: some system-generated child changes do not invoke child triggers. Official docs state that cascading delete operations fire triggers only for records that initiate the delete, not for children deleted by cascade, and child records reparented during merge do not fire triggers either. This explains many “why didn’t my child after delete trigger fire when the parent was removed?” incidents.

Delete, hard delete, undelete, merge, and cascade delete

A regular delete places records in the Recycle Bin for 15 days in most cases; undelete restores them. Salesforce’s trigger model supports before delete, after delete, and after undelete, but after-undelete only works for recovered records and only on top-level supported objects. If you delete an Account and its Opportunity is deleted with it, then undeleting the Account recovers the Opportunity too, but only the Account-level after undelete trigger fires, not the Opportunity one.

Merge does not have a distinct “merge trigger event.” Instead, the platform fires delete events for losing records and an update event for the winning record. The MasterRecordId field is available in Trigger.old only in after delete, which is why merge-specific logic belongs there. The merge sequence is: before delete, system delete/reparent/MasterRecordId set, after delete, then master-record update with normal update triggers. Child records reparented by merge do not fire triggers.

Hard delete is operationally different from soft delete: Database.emptyRecycleBin(...) permanently removes records from the Recycle Bin, and the limits quick reference counts database.emptyRecycleBin against DML statement usage and DML-record processing counts. Design cleanup/audit logic around delete-time events, not around any expectation of a second “hard delete trigger.”

Upsert, external ID behavior, compound fields, and formula fields

Upsert is not “update if Id exists, otherwise insert” only. Salesforce officially supports match keys via record Id, a custom external ID field, or a standard idLookup field. If the key matches zero rows, Salesforce inserts; if it matches exactly one row, Salesforce updates; if it matches multiple rows, Salesforce throws an error and performs neither. For custom-field matching, case-insensitive matching occurs only when the field is unique and configured to treat case variants as duplicates. External-ID upsert can reduce DML count in integration code, but the docs also note that external ID fields used in upsert must be unique or the user must have View All Data.

Compound fields are accessible either as one structured field or as individual component fields. That is useful in SOQL and APIs, but there are limitations and object-specific quirks. For example, Salesforce documents limitations on address compound fields and, in CDC specifically, notes that the Name compound field on AccountChangeEvent is null for person accounts while FirstName and LastName still carry values. Separately, standard trigger docs remind us that person-account DML fires Account triggers, not Contact triggers.

Formula fields are computed dynamically and not stored. In core triggers, you can read them, but because they are not durable stored values, depending on them for “change detection” is often misleading. Salesforce now provides Formula.recalculateFormulas(...), yet CDC explicitly says formula fields are not supported in change event triggers and are null there. A safe architectural inference is that formula-based business conditions often belong either in validation / before-save logic on the source object or in explicit persisted denormalized fields when downstream automation must react reliably.

Approval, assignments, auto-response, escalation, entitlement

Assignment rules, auto-response rules, escalation rules, and entitlement rules are part of the save pipeline, but they are object- and feature-specific, not universal. Assignment and auto-response rules apply to leads and cases; escalation rules are case-centered; entitlement rules run later in the OOE where applicable. Approval processing is not a universal save-step “slot” in the same way; instead, approval actions such as record locking, unlocking, and field updates can themselves update records, and workflow/approval field update actions can optionally reevaluate workflow rules. Also, Approval.process counts against DML statements for governor purposes.

Platform events and Change Data Capture

Platform events and CDC belong in the architecture conversation because many teams use them to decouple trigger-time work. They are not just “async Apex with a different API.”

For platform events, the event bus preserves time order, and Apex platform-event triggers process notifications sequentially in the order received, which is based on replay ID. High-volume platform events are designed for scale, and platform-event publishing/subscription can be configured as Publish Immediately or Publish After Commit. The docs explicitly warn that Publish Immediately is decoupled from the database transaction, so a subscriber may receive the event before the publisher’s transaction commits and then fail to find the corresponding record. Publish After Commit avoids that class of race.

For Change Data Capture, Apex change event triggers run asynchronously after the database transaction is completed, which makes them ideal for downstream synchronization and resource-intensive post-commit processing. CDC publishes create, update, delete, and undelete deltas for supported objects, but formula fields are null in change event triggers, and if the underlying object is not enabled for CDC selection, the trigger can exist without firing. In tests, Test.enableChangeDataCapture() is required, and test delivery is limited to 500 change events.

Architecture patterns and Apex examples

The recommended architecture below is intentionally conservative because Salesforce OOE punishes cleverness more often than it rewards it.

Recommended pattern

Use one trigger per object, with the trigger doing nothing except dispatch. Put orchestration in a handler, domain/business logic in services, and data access in selector/repository-style classes. Add state-based recursion control and idempotency keys where side effects can repeat. This aligns with Salesforce’s non-guaranteed multi-trigger ordering and with architect guidance to design around transaction and automation density intentionally.

A compact reference architecture looks like this:

Single-trigger handler pattern

trigger AccountTrigger on Account (
    before insert, before update,
    after insert, after update,
    before delete, after delete, after undelete
) {
    AccountTriggerHandler.run();
}
public with sharing class AccountTriggerHandler {
    public static void run() {
        if (Trigger.isBefore) {
            if (Trigger.isInsert) beforeInsert(Trigger.new);
            if (Trigger.isUpdate) beforeUpdate(Trigger.new, Trigger.oldMap);
            if (Trigger.isDelete) beforeDelete(Trigger.old);
        } else {
            if (Trigger.isInsert) afterInsert(Trigger.new);
            if (Trigger.isUpdate) afterUpdate(Trigger.new, Trigger.oldMap);
            if (Trigger.isDelete) afterDelete(Trigger.old);
            if (Trigger.isUndelete) afterUndelete(Trigger.new);
        }
    }
    private static void beforeInsert(List<Account> rows) {
        AccountDomain.applyDefaults(rows);
    }
    private static void beforeUpdate(List<Account> rows, Map<Id, Account> oldMap) {
        AccountDomain.normalize(rows, oldMap);
    }
    private static void afterInsert(List<Account> rows) {
        AccountDomain.enqueueIntegration(rows);
    }
    private static void afterUpdate(List<Account> rows, Map<Id, Account> oldMap) {
        AccountDomain.syncParentsIfStateChanged(rows, oldMap);
    }
    private static void beforeDelete(List<Account> rows) {
        AccountDomain.preventProtectedDelete(rows);
    }
    private static void afterDelete(List<Account> rows) {
        AccountDomain.auditDeletes(rows);
    }
    private static void afterUndelete(List<Account> rows) {
        AccountDomain.rehydrateDerivedState(rows);
    }
}

This pattern is not about aesthetics. It is how you force deterministic internal ordering even though Salesforce does not guarantee ordering across multiple triggers on the same event.

Idempotent recursion guard

Avoid a single static Boolean. Prefer a static set keyed by operation plus record Id, and combine it with state-based checks.

public class TriggerGuard {
    private static Set<String> seen = new Set<String>();
    public static Boolean firstTime(String op, Id recordId) {
        String key = op + ':' + String.valueOf(recordId);
        if (seen.contains(key)) return false;
        seen.add(key);
        return true;
    }
}

Example usage:

public with sharing class AccountDomain {
    public static void syncParentsIfStateChanged(List<Account> rows, Map<Id, Account> oldMap) {
        List<Account> updates = new List<Account>();
        for (Account a : rows) {
            Account oldA = oldMap.get(a.Id);
            Boolean newlyQualified =
                oldA.Customer_Status__c != 'Qualified' &&
                a.Customer_Status__c == 'Qualified';
            if (newlyQualified && TriggerGuard.firstTime('AccountQualified', a.Id)) {
                updates.add(new Account(
                    Id = a.ParentId,
                    Last_Qualified_Child__c = a.Id
                ));
            }
        }
        if (!updates.isEmpty()) {
            update updates;
        }
    }
}

The point is not simply “prevent recursion.” The real goal is “allow legitimate later work while preventing duplicate side effects,” especially because workflow field updates and partial-success DML can produce multiple trigger passes in one transaction.

Merge-aware delete logic

trigger ContactMergeAwareTrigger on Contact (after delete, after update) {
    if (Trigger.isDelete) {
        for (Contact oldC : Trigger.old) {
            if (oldC.MasterRecordId != null) {
                System.debug('Contact lost merge to: ' + oldC.MasterRecordId);
                // Merge-specific cleanup here.
            }
        }
    }
}

That logic must live in after delete, because MasterRecordId is populated there for losing records in merge scenarios.

Mixed DML split with queueable

Salesforce documents @future as the classic mixed-DML workaround, but queueable is usually the better modern default because it provides a job Id, supports non-primitive payloads, and supports chaining. Salesforce architect guidance explicitly prefers queueable over future for many asynchronous use cases.

public with sharing class ProvisioningService {
    public static void createBusinessDataAndProvisionUser(Account a, User u) {
        insert a; // non-setup object
        System.enqueueJob(new UserProvisioningJob(u));
    }
    public class UserProvisioningJob implements Queueable {
        private User userToInsert;
        public UserProvisioningJob(User u) {
            this.userToInsert = u;
        }
        public void execute(QueueableContext qc) {
            insert userToInsert; // setup object in separate transaction
        }
    }
}

Callout-safe queueable from trigger-time logic

Official guidance is clear that callouts from trigger-sourced logic must be asynchronous. Future methods can do this with @future(callout=true), but queueable is usually preferable. Also beware savepoint semantics and uncommitted-work errors.

public with sharing class OutboundAccountSync {
    public static void enqueue(Set<Id> accountIds) {
        if (!accountIds.isEmpty()) {
            System.enqueueJob(new SyncJob(new List<Id>(accountIds)));
        }
    }
    public class SyncJob implements Queueable, Database.AllowsCallouts {
        private List<Id> accountIds;
        public SyncJob(List<Id> accountIds) {
            this.accountIds = accountIds;
        }
        public void execute(QueueableContext qc) {
            List<Account> accts = [
                SELECT Id, Name, BillingCity
                FROM Account
                WHERE Id IN :accountIds
            ];
            HttpRequest req = new HttpRequest();
            req.setMethod('POST');
            req.setEndpoint('callout:ERP/accounts');
            req.setHeader('Content-Type', 'application/json');
            req.setBody(JSON.serialize(accts));
            HttpResponse res = new Http().send(req);
            if (res.getStatusCode() >= 300) {
                throw new CalloutException('ERP sync failed: ' + res.getBody());
            }
        }
    }
}

CDC subscriber for decoupled heavy processing

trigger AccountChangeTrigger on AccountChangeEvent (after insert) {
    Set<Id> changedAccountIds = new Set<Id>();
    for (AccountChangeEvent evt : Trigger.New) {
        // Formula fields are null in CDC; use concrete fields and header metadata.
        Id recordId = (Id) evt.ChangeEventHeader.recordIds[0];
        changedAccountIds.add(recordId);
    }
    if (!changedAccountIds.isEmpty()) {
        // Heavy post-commit processing here.
    }
}

This belongs when the job is primarily downstream synchronization or expensive post-commit processing, not when the business rule must block or shape the original save transaction.

Sample test class patterns

A good test suite for trigger architecture should assert behavior under bulk input, recursion-sensitive transitions, negative validation paths, and asynchronous outcomes.

@IsTest
private class AccountTriggerTests {
    @IsTest
    static void bulk_before_and_after_paths() {
        List<Account> rows = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            rows.add(new Account(Name = 'Acct ' + i));
        }
        Test.startTest();
        insert rows;
        Test.stopTest();
        List<Account> inserted = [
            SELECT Id, Name
            FROM Account
            WHERE Id IN :rows
        ];
        System.assertEquals(200, inserted.size());
    }
    @IsTest
    static void queueable_runs_after_stopTest() {
        Account a = new Account(Name = 'Integration Candidate');
        insert a;
        Test.startTest();
        OutboundAccountSync.enqueue(new Set<Id>{a.Id});
        Test.stopTest();
        System.assert(true, 'Queueable executed without unhandled exception');
    }
}

For future/queueable/batch/scheduled tests, Salesforce’s official testing guidance says to bracket the async invocation with Test.startTest() and Test.stopTest() so the collected asynchronous work runs before assertions continue.

A CDC test needs the CDC-specific enablement call:

@IsTest
private class AccountChangeTriggerTests {
    @IsTest
    static void cdc_trigger_fires() {
        Test.enableChangeDataCapture();
        Test.startTest();
        insert new Account(Name = 'CDC Test');
        Test.stopTest();
        System.assert(true, 'CDC trigger fired on stopTest delivery');
    }
}

That pattern is directly aligned with official CDC test guidance.

Limits, testing, and debugging

Limits that most often shape trigger architecture

The following comparison is the practical “why” behind most Salesforce trigger design rules.

AreaCurrent practical limitArchitectural implication
SOQL100 synchronous, 200 asynchronous per transactionCentralize queries and aggregate data access.
Queried rows50,000 per transactionFilter aggressively; avoid “query everything and loop.”
DML statements150 per transactionBuffer writes; avoid row-by-row DML.
DML rows processed10,000 per transactionCross-object fan-out can kill transactions even when DML statement count is low.
Callouts100 per transaction, 120-second cumulative timeoutBatch outbound work or move it to async boundaries.
Future calls50 per Apex invocationDon’t enqueue one future per record.
CPU10,000 ms synchronous, 60,000 ms asynchronousPrefer before-save same-row logic and event/queue decoupling for expensive work.
Trigger recursion depth16Explicit recursion and re-entry controls are mandatory.
Platform event / CDC trigger batch size2,000Subscriber code must still be bulk-safe.

Lesser-known and widely missed behaviors

This is the shortlist I would hand to any senior Salesforce team before a stabilization project.

BehaviorWhy teams miss itWhy it matters
Workflow field updates trigger one more before/after update pass, but not flows/duplicate rules/Process Builder/escalation againMany people remember “workflow can refire triggers” but forget the exact asymmetryExplains double-trigger execution and mismatched flow behavior.
During recursive save, Salesforce skips steps 9–17Rarely memorizedAssignment, auto-response, workflow, escalation, legacy process automation, after-save flows, entitlements, and roll-up phases behave differently in recursion.
Trigger.old after workflow field update shows the original pre-update state, not the intermediate valueVery counterintuitiveNaive delta logic breaks.
Partial-success DML can refire triggers in the same transaction and static variables don’t resetCommon static-Boolean recursion guards ignore this“It worked in one test, failed in bulk load” syndrome.
Multiple triggers on same event have no guaranteed orderLegacy orgs accumulate triggers over yearsForces one-trigger-per-object discipline.
Cascade delete of children does not fire child delete triggersEasily assumed otherwiseCleanup logic must often live on the parent delete path.
Child reparenting due to merge does not fire child triggersOften discovered only in data dedupe projectsMerge-safe algorithms must inspect losing rows and post-merge winners.
After undelete runs only on supported top-level objectsOverlooked in recovery logicChild undelete trigger expectations are often wrong.
Person-account DML fires Account triggers, not Contact triggersHidden unless you work in B2C orgsTrigger coverage and side effects can be incomplete.
Formula fields are computed, not storedPeople treat them like durable columnsGreat for reads; dangerous for event detection and CDC assumptions.
CDC formula fields are nullEasy to miss when moving logic from triggers to event subscribersEvent subscribers need persisted source fields or denormalized state.
Publish Immediately platform events can arrive before the source record commitsSurprising to teams expecting transactional publicationUse Publish After Commit when subscribers must see committed records.

Debugging checklist for OOE issues

When a save path behaves strangely, debug in this sequence.

  1. Confirm the operation type: insert, update, upsert, delete, undelete, merge, cascade delete, or post-commit/event-driven subscriber. Many incidents are misclassified from the start.
  2. Identify the automation families involved on the object: before-save flow, trigger, validation, workflow, Process Builder, after-save flow, parent/grandparent recalculation, sharing, async path, queueable/future, platform event, CDC.
  3. Check whether the record passed through a workflow field update or recursive save branch. These branches explain many “ran twice/ran once/not at all” outcomes.
  4. If the symptom is on a child object, ask whether the child change was direct or system-generated by cascade delete or merge reparent.
  5. If the symptom is cross-object, inspect roll-up summary/cross-object workflow and parent/grandparent save procedures.
  6. If integration is involved, determine whether publication/subscription was in-transaction, post-commit, Publish Immediately, Publish After Commit, queueable, future, or CDC.
  7. If a setup object appears anywhere, test for mixed DML.
  8. If the issue is non-deterministic, audit for multiple triggers on the same event or unordered legacy automation.
  9. In tests, make sure async delivery patterns are correct: startTest/stopTest, Test.enableChangeDataCapture(), Test.getEventBus().deliver().

Interview questions and model answers

Expert-level questions

How do before-save flows, before triggers, after triggers, workflow field updates, and after-save flows interleave on a simple update? Answer outline: Current OOE is before-save flows → before triggers → validation/duplicate rules → save (no commit) → after triggers → assignment/auto-response/workflow. If workflow field update occurs, Salesforce updates again and reruns before/after update triggers one extra time only, without rerunning flows/duplicate rules/Process Builder/escalation. After-save record-triggered flows occur later, before entitlements in current docs.

Why is a single static Boolean a poor recursion guard in Salesforce? Answer outline: Because recursion sources include workflow re-entry and partial-success DML retries. Salesforce docs say static variables aren’t reset across trigger invocations in the same transaction when partial success is allowed. Therefore, state-based idempotency keyed by record and transition is safer than “already ran.”

A parent delete caused child records to disappear, but child delete triggers never fired. Explain. Answer outline: Cascade delete. Official docs: only records that initiate a delete cause trigger evaluation. Child delete triggers don’t fire for system cascade deletes. Parent-side logic must handle cleanup if child-side trigger behavior is required.

What exactly fires on merge, and where do you detect losing records? Answer outline: Merge does not fire a separate merge trigger event. It fires delete for losing records and update for the winning record. MasterRecordId is available on losing records in after delete, so merge-aware cleanup belongs there. Child reparenting due to merge does not fire child triggers.

How would you choose between after-trigger, queueable, platform event, and CDC for integration? Answer outline:

  • Use after-trigger only for same-transaction invariants on Salesforce data.
  • Use queueable for outbound callouts or post-commit processing tightly coupled to the transaction outcome.
  • Use platform event for decoupled event-driven orchestration, especially across multiple subscribers. Use Publish After Commit if subscribers must see committed records.
  • Use CDC for downstream data synchronization of record deltas after commit; not for enforcing same-transaction rules.

What is the most dangerous misconception about formula fields in trigger architecture?
Answer outline: Treating them as stored state or as reliable event boundaries. Officially they are computed dynamically, and CDC explicitly says formula fields are null in change event triggers. If automation must react to the change, persist the state or react on the source object.

Tricky scenario-based problems

Scenario: You insert 200 Opportunities. An after-insert trigger updates parent Accounts. A workflow field update on Account then changes a field that makes an after-save flow on Account update child Contacts. Some transactions fail only in data load, not from the UI. Where do you look first?
Answer outline: Bulk limits and re-entry. The Opportunity trigger fan-out to Accounts can start parent save procedures; workflow field updates cause one extra trigger pass; Contact updates add more DML/queries. Investigate per-transaction SOQL/DML/CPU, roll-up/cross-object effects, and whether idempotency prevents repeated parent or child updates. Also verify whether multiple triggers or unordered legacy automation exist.

Scenario: A trigger publishes a platform event and the subscriber tries to query the record that published it but sometimes cannot find it.
Answer outline: Likely Publish Immediately behavior. Official docs say Publish Immediately is decoupled from DB transaction commit, so subscriber can receive the event before the record commits. Change the event to Publish After Commit if committed visibility is required.

Scenario: A test that inserts an Account should fire a CDC trigger, but coverage is zero.
Answer outline: In tests, call Test.enableChangeDataCapture() before DML, then either Test.getEventBus().deliver() or Test.startTest()/Test.stopTest() to deliver messages. Without that, the change event trigger may not fire in test context.

Scenario: An integration performs account upserts by external ID and occasionally creates duplicates instead of updating. Why?
Answer outline: Upsert matches by Id, unique External ID, or standard idLookup field. If the external-ID field isn’t unique and the user lacks View All Data, matching behavior can fail. Also, if matching multiple rows, Salesforce throws an error rather than updating. Confirm field uniqueness, case settings, and the specific key used in the upsert.

Scenario: Updating a User and an Account in the same transaction throws mixed-DML errors in production but not in a deployment test. Why?
Answer outline: Mixed DML is restricted in runtime transactions because setup-object changes affect access semantics. Salesforce also notes validation for mixed DML is skipped during deployment, so test failure counts can differ between deployment and UI execution. Split the work into separate transactions.

Open questions and limitations

Some behaviors are explicitly versioned or feature-specific, so exact runtime results can vary by class/trigger API version, enabled features, and automation mix. The main official versioned nuance called out here is the placement of after-save record-triggered flows relative to entitlement rules in API 53.0 and earlier versus current behavior. Edition-specific feature availability, object-specific entitlement/assignment/escalation applicability, and any org-specific managed-package automation were intentionally not assumed unless the official docs stated them.

Leave a Reply