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 step | What happens | Important nuance |
|---|---|---|
| 1 | Load old record from DB, or initialize for upsert | Upsert starts by loading old state if matched, or initializing new state if not. |
| 2 | Load new field values from request | Validation differs by source: standard UI, multiline item creation, Apex/API, and Salesforce checks custom foreign keys for self-reference before triggers. |
| 3 | Run before-save record-triggered flows | Current docs place before-save flows before Apex before triggers. |
| 4 | Run all before triggers | Best place for same-record field mutation and addError() on the current row. |
| 5 | Re-run most system validation and custom validation rules | Layout-specific rules are not re-enforced on the second pass for standard UI edit pages. |
| 6 | Execute duplicate rules | If a duplicate rule blocks, the record is not saved and after-trigger/workflow steps do not occur. |
| 7 | Save record to DB, not yet committed | Database-generated IDs now exist for inserts, but rollback is still possible. |
| 8 | Run all after triggers | Same-row Trigger.new is read-only here. Use separate DML only if truly necessary. |
| 9 | Assignment rules | Applies when relevant rules exist; assignment rules are for leads and cases. |
| 10 | Auto-response rules | Applies to leads and cases when configured. |
| 11 | Workflow rules | If 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. |
| 12 | Escalation rules | This mainly matters for cases. |
| 13 | Legacy Flow automations | Current 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. |
| 14 | After-save record-triggered flows | Current docs place these before entitlement rules. In API 53.0 and earlier they ran after entitlements. |
| 15 | Entitlement rules | Current location in the sequence for modern API versions. |
| 16 | Parent roll-up summary / cross-object workflow recalculation | Parent goes through save procedure if recalculated/updated. |
| 17 | Grandparent roll-up summary / cross-object workflow recalculation | Grandparent also goes through save procedure if applicable. |
| 18 | Criteria-based sharing evaluation | Happens before commit, but late in the transaction. |
| 19 | Commit | Durable write point. |
| 20 | Post-commit logic | Examples 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.
flowchart TD
A[Request arrives] --> B[Load old/new values]
B --> C[System validation pass]
C --> D[Before-save record-triggered flows]
D --> E[Before triggers]
E --> F[Validation pass plus custom validation]
F --> G[Duplicate rules]
G --> H[Save record no commit]
H --> I[After triggers]
I --> J[Assignment rules]
J --> K[Auto-response rules]
K --> L[Workflow rules]
L --> M{Workflow field update?}
M -- No --> N[Escalation rules]
M -- Yes --> L1[Update record again]
L1 --> L2[System validation again]
L2 --> L3[Before and after update triggers one extra time]
L3 --> N[Escalation rules]
N --> O[Process Builder plus workflow-launched flows]
O --> P[After-save record-triggered flows]
P --> Q[Entitlement rules]
Q --> R[Parent roll-up or cross-object workflow]
R --> S[Grandparent roll-up if needed]
S --> T[Criteria-based sharing]
T --> U[Commit]
U --> V[Post-commit email async Apex async flow paths]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.
| Pattern | Best use | Main advantage | Main risk |
|---|---|---|---|
| Before trigger | Same-record field derivation, validation, addError() | No extra DML for current row; earliest Apex hook after before-save flow | No record Id yet on insert; relationship side effects often premature. |
| After trigger | Related-record work, logic requiring committed row identity | Record Id exists; can create/update related data | Trigger.new is read-only; same-row update requires extra DML and recursion control. |
| Before-save flow | Simple same-row updates/validation | Earliest declarative same-row hook; runs before before triggers | Same-row only; ordering only among same trigger-type flows. |
| After-save flow | Declarative related-record work, notifications, async path | Strong admin-first option for post-save automation | Can 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:
flowchart LR
A[Workflow rule] --> B[Possible workflow field update]
B --> C[One extra trigger update pass only]
C --> D[Escalation]
D --> E[Process Builder plus workflow-launched flow unordered]
E --> F[After-save record-triggered flows ordered among peers]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.
flowchart TD
C[Child record insert or update] --> P1[Child trigger or flow]
P1 --> P2[Parent roll-up or cross-object update]
P2 --> P3[Parent save procedure]
P3 --> G1[Grandparent roll-up if applicable]
G1 --> G2[Grandparent save procedure]
G2 --> S[Criteria-based sharing evaluation]
S --> COMMIT[Commit]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:
flowchart TD
T[Single object trigger] --> H[Trigger handler]
H --> G[Context gate and feature gate]
G --> D[Domain/service logic]
D --> S[Selector or repository]
D --> U[Unit of work or bulk DML buffer]
D --> A[Async boundary if needed]
A --> Q[Queueable or platform event or CDC subscriber]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.
| Area | Current practical limit | Architectural implication |
|---|---|---|
| SOQL | 100 synchronous, 200 asynchronous per transaction | Centralize queries and aggregate data access. |
| Queried rows | 50,000 per transaction | Filter aggressively; avoid “query everything and loop.” |
| DML statements | 150 per transaction | Buffer writes; avoid row-by-row DML. |
| DML rows processed | 10,000 per transaction | Cross-object fan-out can kill transactions even when DML statement count is low. |
| Callouts | 100 per transaction, 120-second cumulative timeout | Batch outbound work or move it to async boundaries. |
| Future calls | 50 per Apex invocation | Don’t enqueue one future per record. |
| CPU | 10,000 ms synchronous, 60,000 ms asynchronous | Prefer before-save same-row logic and event/queue decoupling for expensive work. |
| Trigger recursion depth | 16 | Explicit recursion and re-entry controls are mandatory. |
| Platform event / CDC trigger batch size | 2,000 | Subscriber 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.
| Behavior | Why teams miss it | Why it matters |
|---|---|---|
| Workflow field updates trigger one more before/after update pass, but not flows/duplicate rules/Process Builder/escalation again | Many people remember “workflow can refire triggers” but forget the exact asymmetry | Explains double-trigger execution and mismatched flow behavior. |
| During recursive save, Salesforce skips steps 9–17 | Rarely memorized | Assignment, 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 value | Very counterintuitive | Naive delta logic breaks. |
| Partial-success DML can refire triggers in the same transaction and static variables don’t reset | Common static-Boolean recursion guards ignore this | “It worked in one test, failed in bulk load” syndrome. |
| Multiple triggers on same event have no guaranteed order | Legacy orgs accumulate triggers over years | Forces one-trigger-per-object discipline. |
| Cascade delete of children does not fire child delete triggers | Easily assumed otherwise | Cleanup logic must often live on the parent delete path. |
| Child reparenting due to merge does not fire child triggers | Often discovered only in data dedupe projects | Merge-safe algorithms must inspect losing rows and post-merge winners. |
| After undelete runs only on supported top-level objects | Overlooked in recovery logic | Child undelete trigger expectations are often wrong. |
| Person-account DML fires Account triggers, not Contact triggers | Hidden unless you work in B2C orgs | Trigger coverage and side effects can be incomplete. |
| Formula fields are computed, not stored | People treat them like durable columns | Great for reads; dangerous for event detection and CDC assumptions. |
| CDC formula fields are null | Easy to miss when moving logic from triggers to event subscribers | Event subscribers need persisted source fields or denormalized state. |
| Publish Immediately platform events can arrive before the source record commits | Surprising to teams expecting transactional publication | Use Publish After Commit when subscribers must see committed records. |
Debugging checklist for OOE issues
When a save path behaves strangely, debug in this sequence.
- 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.
- 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.
- 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.
- If the symptom is on a child object, ask whether the child change was direct or system-generated by cascade delete or merge reparent.
- If the symptom is cross-object, inspect roll-up summary/cross-object workflow and parent/grandparent save procedures.
- If integration is involved, determine whether publication/subscription was in-transaction, post-commit, Publish Immediately, Publish After Commit, queueable, future, or CDC.
- If a setup object appears anywhere, test for mixed DML.
- If the issue is non-deterministic, audit for multiple triggers on the same event or unordered legacy automation.
- 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.
