Skip to content

AfterUpdate.RecursionGuard

Caps how many times an after update Writer or Dispatcher acts on the same record in one transaction. Without it, the limit is 3. It exists only in the update contexts.

Interface

apex
public class AfterUpdate {
    public interface RecursionGuard {
        Integer maxRecursionDepthOnAfterUpdate();
    }
}
  • maxRecursionDepthOnAfterUpdate(): 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.

Example

apex
public with sharing class ContactWriter implements AfterUpdate.Writer, AfterUpdate.RecursionGuard {
    public Integer maxRecursionDepthOnAfterUpdate() {
        return 1;
    }

    public Boolean writeOnAfterUpdateWhen(TriggerHandler.UpdateRecord record) {
        return record.isChanged(Contact.AccountId);
    }

    public void writeOnAfterUpdate(TriggerHandler.UpdateRecord record, TriggerHandler.UnitOfWork unitOfWork) {
        unitOfWork.toInsert(new Task(WhatId = ((Contact) record.getNewSObject()).AccountId, Subject = 'Review contact'));
    }
}
cls
public with sharing class OpportunityAccountTypeWriter implements AfterUpdate.Writer, AfterUpdate.ParentQuery, AfterUpdate.RecursionGuard {
    public Map<SObjectField, TriggerHandler.ParentFields> queryParentsOnAfterUpdate() {
        return new Map<SObjectField, TriggerHandler.ParentFields>{ Opportunity.AccountId => TriggerHandler.ParentFields.with(Account.Type) };
    }

    public Integer maxRecursionDepthOnAfterUpdate() {
        return 1;
    }

    public Boolean writeOnAfterUpdateWhen(TriggerHandler.UpdateRecord record) {
        Account parentAccount = (Account) record.getNewParent('Account');

        return record.isChangedTo(Opportunity.StageName, 'Closed Won') && parentAccount != null && parentAccount.Type != 'Customer - Direct';
    }

    public void writeOnAfterUpdate(TriggerHandler.UpdateRecord record, TriggerHandler.UnitOfWork unitOfWork) {
        Opportunity newOpportunity = (Opportunity) record.getNewSObject();

        unitOfWork.toUpdate(new Account(Id = newOpportunity.AccountId, Type = 'Customer - Direct'));
    }
}
cls
public with sharing class AccountAddressCascadeWriter implements AfterUpdate.Writer, AfterUpdate.RelatedQuery, AfterUpdate.ContinueOnError, AfterUpdate.RecursionGuard {
    public Integer maxRecursionDepthOnAfterUpdate() {
        return 1;
    }

    public Map<String, AfterUpdate.RecordsProvider> queryRelatedOnAfterUpdate() {
        return new Map<String, AfterUpdate.RecordsProvider>{ 'contacts' => new AccountContactsProvider() };
    }

    public Boolean writeOnAfterUpdateWhen(TriggerHandler.UpdateRecord record) {
        return record.isAnyChanged(Account.BillingStreet, Account.BillingCity, Account.BillingState, Account.BillingPostalCode, Account.BillingCountry);
    }

    public void writeOnAfterUpdate(TriggerHandler.UpdateRecord record, TriggerHandler.UnitOfWork unitOfWork) {
        Account newAddress = (Account) record.getNewSObject();
        Account previousAddress = (Account) record.getOldSObject();

        for (SObject relatedRecord : record.getRelated('contacts').getAllWhereKeyEquals(record.getId())) {
            Contact contactRecord = (Contact) relatedRecord;

            if (!this.mailingFollowedAccount(contactRecord, previousAddress)) {
                continue;
            }

            unitOfWork.toUpdate(
                new Contact(
                    Id = contactRecord.Id,
                    MailingStreet = newAddress.BillingStreet,
                    MailingCity = newAddress.BillingCity,
                    MailingState = newAddress.BillingState,
                    MailingPostalCode = newAddress.BillingPostalCode,
                    MailingCountry = newAddress.BillingCountry
                )
            );
        }
    }

    private Boolean mailingFollowedAccount(Contact contactRecord, Account previousAddress) {
        if (String.isBlank(contactRecord.MailingStreet) && String.isBlank(contactRecord.MailingCity)) {
            return true;
        }

        return contactRecord.MailingStreet == previousAddress.BillingStreet &&
            contactRecord.MailingCity == previousAddress.BillingCity &&
            contactRecord.MailingPostalCode == previousAddress.BillingPostalCode &&
            contactRecord.MailingCountry == previousAddress.BillingCountry;
    }

    private with sharing class AccountContactsProvider implements AfterUpdate.RecordsProvider {
        public List<SObject> query(TriggerHandler.UpdateRecords records) {
            return [
                SELECT Id, AccountId, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry
                FROM Contact
                WHERE AccountId IN :records.getIds()
            ];
        }

        public String keyOf(SObject record) {
            return ((Contact) record).AccountId;
        }
    }
}

Good to Know

  • It counts passes, not nesting depth. The count goes up each time the predicate returns true and never resets during the transaction. Separate updates of the same record count too.
  • Skipped silently. A record at the limit is skipped before its predicate. Nothing is logged, and the Finalizer does not get it.
  • Prefer a change gate. isChanged or isChangedTo in the predicate stops re-entry without counting.
  • Counted per class name and context. Two instances of one class share one count. BeforeUpdate keeps a separate count.
  • 0 or less stops the handler. Every record is skipped. null means no limit.