Skip to content

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, isChanged compares 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.