Errors & Logging
An exception in a handler is logged, then rethrown, and the save fails. With ContinueOnError it is logged and swallowed instead.
What Happens to an Exception
A handler's work is its providers, predicates, actions, dispatch, Finalizer and its own unit of work's commit. An exception there:
- Is logged. The org's Logger gets it under the handler's name.
- Is rethrown, unless the handler implements ContinueOnError. Every record in the chunk fails. With all-or-none DML, the whole statement fails.
- Is always rethrown when it is the library's own
TriggerTypes.TriggerLibException, such as DML in a before insert handler.
Platform exceptions such as System.LimitException cannot be caught. They fail the save, and the Logger never sees them.
Logger
Implement TriggerOrchestrator.Logger. The library finds it; nothing is registered. This Logger publishes a Publish Immediately platform event of your own, so the log survives a failed save:
public with sharing class TriggerErrorLogger implements TriggerOrchestrator.Logger {
private List<TriggerError__e> events = new List<TriggerError__e>();
public void log(TriggerOrchestrator.Error error) {
this.events.add(
new TriggerError__e(
Handler__c = error.getHandlerName(),
Operation__c = String.valueOf(error.getOperation()),
Object__c = String.valueOf(error.getSObjectType()),
RecordIds__c = String.join(new List<Id>(error.getRecordIds()), ','),
Message__c = error.getException().getMessage(),
StackTrace__c = error.getException().getStackTraceString()
)
);
}
public void flush() {
if (this.events.isEmpty()) {
return;
}
EventBus.publish(this.events);
this.events.clear();
}
}- One instance per transaction.
flush()runs at the end of each outermost run. Clear your buffer there. - Exactly one class. With two implementations, every Trigger Lib trigger in the org throws.
- Never throw from a Logger. Its exception replaces the handler's and fails the save.
ContinueOnError
Implement the context's ContinueOnError to let the save go on when the handler fails.
- The failed handler stops. Its remaining records and its Finalizer are skipped. Later handlers still run.
- A Writer that throws before its commit saves nothing. Its registrations go to the automatic unit of work, which is skipped after a failure. If the commit itself fails, statements that already ran stay: there is no savepoint.
- Use it for nice-to-have work, such as a notification. Without a Logger, a swallowed failure leaves no trace.
Never Logged
This code runs outside every handler. An exception there is not logged, ContinueOnError does not apply, and the save fails:
<ctx>Handlers();bypassOn<Ctx>When(),ownUnitOfWorkOn<Ctx>()andmaxRunsPerRecordOn<Ctx>();- the ParentQuery and PriorParentQuery methods, and the parent query after a Populator changes a lookup;
- the shared unit of work's commit.
Fail One Record, Not the Whole Save
Catch the exception and add an error to the record that caused it:
public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) {
try {
unitOfWork.toInsert(this.onboardingTaskFor(record));
} catch (Exception e) {
record.getNewSObject().addError('No onboarding task could be created: ' + e.getMessage());
}
}- Delete contexts use
record.getOldSObject().addError(…). - All-or-none DML still fails, but the error names the record. With partial success, the other records save.
- To reject a record on purpose, use a Validator.
