Skip to content

AfterUpdate.ContinueOnError

Logs and swallows the exceptions of an after update Writer or Dispatcher, so later handlers still run and the update is saved. A Writer also gets a private unit of work, so a failure discards only its own writes.

Interface

apex
public class AfterUpdate {
    public interface ContinueOnError {
    }
}

AfterUpdate.ContinueOnError has no methods: implementing it is the whole opt-in.

Example

apex
public with sharing class ContactWriter implements AfterUpdate.Writer, AfterUpdate.ContinueOnError {
    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 OpportunityWinTaskWriter implements AfterUpdate.Writer, AfterUpdate.ParentQuery, AfterUpdate.ContinueOnError {
    public Map<SObjectField, TriggerHandler.ParentFields> queryParentsOnAfterUpdate() {
        return new Map<SObjectField, TriggerHandler.ParentFields>{ Opportunity.AccountId => TriggerHandler.ParentFields.with(Account.Name) };
    }

    public Boolean writeOnAfterUpdateWhen(TriggerHandler.UpdateRecord record) {
        return record.isChangedTo(Opportunity.StageName, 'Closed Won');
    }

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

        unitOfWork.toInsert(
            new Task(
                WhatId = record.getId(),
                OwnerId = newOpportunity.OwnerId,
                Subject = 'Onboarding kick-off: ' + (parentAccount?.Name ?? newOpportunity.Name),
                Description = 'Won on ' + Date.today().format() + '. Agree the implementation plan with the customer.',
                ActivityDate = Date.today().addDays(7),
                Priority = 'High',
                Status = 'Not Started'
            )
        );
    }
}
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

  • The handler stops. After an exception, its remaining records and its Finalizer are skipped. Later handlers still run.
  • The private unit commits early. It commits right after the Writer, before the unit of work the other Writers share. A Writer that also implements OwnUnitOfWork gets its own unit instead.
  • A Dispatcher keeps what it did. A job already enqueued or an event already published stays.
  • Some errors still fail the update. TriggerHandler.TriggerHandlerException, System.LimitException and the FinalException from writing to a trigger row are never swallowed. Neither are errors in bypassOnAfterUpdateWhen(), in the parent queries or in the commit after the last handler.
  • Add a Logger. Without a TriggerOrchestrator.Logger implementation, a swallowed exception leaves no trace. See Errors & Logging.