BeforeUpdate.RecursionGuard
Limits how many times a Populator acts on the same record in one transaction. Without it, the limit is 3.
Interface
apex
public class BeforeUpdate {
public interface RecursionGuard {
Integer maxRecursionDepthOnBeforeUpdate();
}
}maxRecursionDepthOnBeforeUpdate(): called once per chunk, when the handler list is built, even for a handler that is then bypassed. Returns how many times one record may qualify for this handler in the transaction.
Only a Populator uses it; a Validator ignores it.
Example
apex
public with sharing class ContactPopulator implements BeforeUpdate.Populator, BeforeUpdate.RecursionGuard {
public Integer maxRecursionDepthOnBeforeUpdate() {
return 1;
}
public Boolean populateOnBeforeUpdateWhen(TriggerHandler.UpdateRecord record) {
return record.isChanged(Contact.Email);
}
public void populateOnBeforeUpdate(TriggerHandler.UpdateRecord record) {
record.put(Contact.HasOptedOutOfEmail, false);
}
}cls
public with sharing class OpportunityClosingPopulator implements BeforeUpdate.Populator, BeforeUpdate.RecursionGuard {
public Integer maxRecursionDepthOnBeforeUpdate() {
return 1;
}
public Boolean populateOnBeforeUpdateWhen(TriggerHandler.UpdateRecord record) {
Opportunity newOpportunity = (Opportunity) record.getNewSObject();
return record.isChanged(Opportunity.StageName) && new Set<String>{ 'Closed Won', 'Closed Lost' }.contains(newOpportunity.StageName);
}
public void populateOnBeforeUpdate(TriggerHandler.UpdateRecord record) {
Opportunity newOpportunity = (Opportunity) record.getNewSObject();
Opportunity oldOpportunity = (Opportunity) record.getOldSObject();
record.put(Opportunity.NextStep, null);
String closingNote = newOpportunity.StageName + ' by ' + UserInfo.getName() + ' on ' + Date.today().format() + ' (previous stage: ' + oldOpportunity.StageName + ')';
record.put(Opportunity.Description, record.isBlank(Opportunity.Description) ? closingNote : newOpportunity.Description + '\n' + closingNote);
}
}cls
public with sharing class AccountTypeAuditPopulator implements BeforeUpdate.Populator, BeforeUpdate.RecursionGuard {
public Integer maxRecursionDepthOnBeforeUpdate() {
return 1;
}
public Boolean populateOnBeforeUpdateWhen(TriggerHandler.UpdateRecord record) {
return record.isChanged(Account.Type);
}
public void populateOnBeforeUpdate(TriggerHandler.UpdateRecord record) {
Account accountRecord = (Account) record.getNewSObject();
Account oldAccount = (Account) record.getOldSObject();
String auditLine =
Datetime.now().format('yyyy-MM-dd HH:mm') +
' - Type ' +
(oldAccount.Type ?? 'none') +
' -> ' +
(accountRecord.Type ?? 'none') +
' by ' +
UserInfo.getName();
record.put(Account.Description, this.stampedDescription(auditLine, accountRecord.Description));
}
private String stampedDescription(String auditLine, String description) {
String stamped = String.isBlank(description) ? auditLine : auditLine + '\n' + description;
return stamped.length() > 32000 ? stamped.left(32000) : stamped;
}
}Good to Know
- Update contexts only. RecursionGuard exists in BeforeUpdate and AfterUpdate, and each keeps its own count.
- It counts qualifying passes. The count goes up by 1 each time the predicate returns true for a record Id. It lasts the whole transaction and is not the nesting level.
- A change gate often does the job. On a nested update,
isChangedcompares with the values the previous update saved, so it is false unless the field changed again. - A limit of 1 blocks a second edit. When one transaction updates a record twice, both updates qualify, and the second one is skipped.
- Skipped records vanish silently. A skipped record is not logged, and the Finalizer does not get it.
