8.4 Recursion, Cascading & Transaction Control
Key Takeaways
- Recursive trigger re-entry and cascading master-detail/roll-up updates multiply DML rows, SOQL, and CPU in one transaction—design selective logic and static guards
- Static Boolean or Set<Id> recursion guards stop infinite loops; prefer Id sets when some records still need processing on re-entry
- System.isBatch, isFuture, isQueueable, isScheduled help branch logic so interactive and async contexts do not double-apply side effects
- Database.setSavepoint and rollback undo uncommitted work in the transaction; understand partial failure vs full rollback behavior
- Mixed DML occurs when non-setup and setup objects are written in the same synchronous transaction—split with async or redesign the unit of work
8.4 Recursion, Cascading & Transaction Control
Quick Answer: Trigger recursion happens when your DML (or automation you cause) saves records that fire the same trigger again. Cascading master-detail and roll-up updates chain saves across objects. Control re-entry with static Boolean or Set<Id> guards, branch on System.isBatch / isFuture / isQueueable when async contexts should behave differently, use savepoints to roll back partial work intentionally, and avoid mixed DML by not updating setup and non-setup objects together in one synchronous transaction.
Section 8.2 introduced bulk patterns and a simple recursion flag. This section deepens failure modes the exam loves: infinite loops, parent/child storms, async double-fire, transaction rollback, and mixed DML exceptions.
How Recursion Starts
Common recursion loops:
- After-update trigger updates the same records → before/after update fires again
- After-update updates children → child trigger updates parent → parent trigger updates children
- Flow or workflow field update re-enters triggers (partial OOE re-entry)
- Roll-up updates parent → parent after logic updates children again
- Unselective logic runs on every re-entry even when business data did not meaningfully change
Without a guard, recursion ends when a governor limit is hit (CPU time, DML statements, DML rows) or a depth-related failure surfaces—rarely with a friendly business error.
Static Boolean Guard
public with sharing class CaseTriggerHandler {
private static Boolean isHandling = false;
public static void afterUpdate(List<Case> news, Map<Id, Case> oldMap) {
if (isHandling) {
return;
}
isHandling = true;
try {
CaseService.syncRelatedAccounts(news, oldMap);
} finally {
isHandling = false;
}
}
}
Pros: Simple; stops all re-entrant runs of that method for the stack region you wrap.
Cons: Too coarse if legitimate bulk work needs a second pass for other records in a complex chain; resetting the flag in finally allows later separate operations in the same transaction (for example, a second independent DML from a different code path) to run again—which is often desired.
Place the guard at the handler boundary so all service work is covered. Guarding only one helper while another helper still re-enters leaves holes.
Static Set<Id> Guard (Finer Control)
public with sharing class OpportunityTriggerHandler {
private static Set<Id> alreadyProcessed = new Set<Id>();
public static void afterUpdate(List<Opportunity> news, Map<Id, Opportunity> oldMap) {
List<Opportunity> firstTime = new List<Opportunity>();
for (Opportunity o : news) {
if (alreadyProcessed.contains(o.Id)) {
continue;
}
alreadyProcessed.add(o.Id);
firstTime.add(o);
}
if (firstTime.isEmpty()) {
return;
}
OpportunityService.applyAfterUpdateSideEffects(firstTime, oldMap);
}
}
Use Id sets when:
- The same trigger may fire multiple times for overlapping Ids
- You still want to process new Ids that appear later in the transaction
- Boolean would incorrectly skip unrelated records
Remember static state lasts for the transaction (request), not forever across user sessions. Tests should reset or avoid depending on polluted statics between methods when isolation matters—use Test.startTest/stopTest carefully and design handlers to be test-friendly.
Cascading Master-Detail and Roll-Ups
Master-detail relationships cascade deletes and can cascade reparenting behavior depending on configuration. Roll-up summary fields on the master recalculate when detail records change, which updates the master and invokes the master’s order of execution.
Amplification example
- 200 OpportunityLineItem updates in one chunk
- Each touches roll-ups on Opportunity (and possibly Account via further roll-ups)
- Parent Opportunity triggers, Flows, and validation run
- If parent logic updates each child again, row counts explode
Mitigations
- Selective entry: only run heavy logic when watched fields change
- Aggregate in maps; update each parent once
- Prefer platform roll-ups over hand-written parent counters when they fit
- Move non-urgent fan-out to Queueable / batch so interactive saves stay light
- Avoid bidirectional “child updates parent updates child” without Id guards
Async Context Awareness
Side effects that should not double-run in async re-processing check context:
if (System.isFuture() || System.isQueueable()) {
// skip enqueueing another future/queueable that would chain unboundedly
return;
}
if (System.isBatch()) {
// use bulk paths safe for large scopes; avoid per-row @future
}
| Method | True when |
|---|---|
System.isBatch() | Running in batch Apex execute/context |
System.isFuture() | Inside @future method |
System.isQueueable() | Inside Queueable execute |
System.isScheduled() | Scheduled Apex job context |
System.isQueueable() / combined checks | Design async chains intentionally |
Exam patterns
- Do not call
@futurefrom a context that is already future (platform restriction) - Batch + callouts need
Database.AllowsCalloutsand bulk design - Triggers that enqueue async work on every recursive pass can create job storms—combine recursion guards with async checks
Savepoints and Rollbacks
Apex can mark a savepoint and roll back DML to that point within the open transaction:
Savepoint sp = Database.setSavepoint();
try {
insert primaryRecords;
insert relatedRecords; // suppose this throws
} catch (DmlException e) {
Database.rollback(sp);
// primaryRecords inserts are undone; transaction can continue or rethrow
throw e; // often rethrow after cleanup/logging
}
Key facts
- Rollback undoes DML after the savepoint; it does not undo static variable changes in memory—reset guards carefully if you continue the transaction
- Nested savepoints are possible; roll back to a specific savepoint
- Once the transaction commits, savepoints from that transaction are gone—no cross-request undo
- Rolling back and then continuing requires clear product behavior so users are not shown success for undone work
Databasemethods with partial success (allOrNone = false) offer row-level error handling without necessarily using savepoints—choose the tool that matches the business need
Savepoints are for controlled compensation inside complex multi-step Apex, not a substitute for validation that should have blocked the save in before context.
Mixed DML and Setup Objects
Salesforce separates setup objects (for example Group, GroupMember, UserRole, some User DML scenarios, Permission Set assignments—consult current docs for the exact list) from non-setup sObjects (Account, Contact, custom business objects). Performing DML on both categories in the same synchronous transaction throws a mixed DML error.
// Problematic pattern (illustrative):
insert new Account(Name = 'Acme');
insert new Group(Name = 'Acme Team', Type = 'Regular'); // setup-related
// → Mixed DML exception risk
Typical solutions
- Move one category’s DML to async (
@future, Queueable) so it runs in a separate transaction - Restructure so a single interactive transaction only touches one category
- In tests, use
System.runAsand patterns documented for setup object testing; isolate setup DML
Exam stems often show User/Permission Set work combined with Account inserts—recognize mixed DML and pick async split or redesign.
Transaction Boundaries (Big Picture)
- One user save or one Apex request shares one set of governors until commit or hard failure
- Uncaught exceptions roll back uncommitted work for that transaction (with nuances around email/async already enqueued—design assuming failure should not leave partial business state)
Test.startTest()/Test.stopTest()provide a fresh governor window for async in tests—not a production transaction split- Cascading automations stay inside the same transaction until commit—hence OOE + recursion + roll-ups all share limits
Design Checklist for Stable Triggers
- Bulkify first (collect/query/map/DML once)
- Change-detect so re-entry has less work even if it occurs
- Add static Boolean or Set<Id> guards around after-context side effects
- Prefer before-context same-record edits to avoid self-updates
- Mind parent roll-up storms on master-detail
- Gate async enqueue with
System.isFuture/isQueueable/isBatch - Use savepoints only for intentional multi-step compensation
- Split setup vs non-setup DML across transactions
Exam Scenario Drill
- Infinite loop symptoms (CPU timeout, too many DML) → missing recursion guard or after-update self-DML
- Works for one record, fails at 200 → not bulkified, not primarily a savepoint issue
- Mixed DML message → setup + non-setup in one sync transaction
- Logic ran in UI and again in batch → missing context checks or unselective statics
- Partial inserts visible after error → understand allOrNone vs partial Database methods vs rollback
Master recursion and transaction control and you close the loop on trigger design: correct context (8.1), bulk structure (8.2), OOE placement (8.3), and safe re-entry under load (8.4).
An after-update Opportunity trigger updates related Account records, and an Account after-update trigger updates Opportunities. What is the primary risk, and what is a common mitigation?
A developer must insert a business Account and create a public Group in one user action. Which approach avoids mixed DML errors?
Why might a static Set<Id> recursion guard be preferable to a single static Boolean in a bulk after-update handler?