TriggerHandler base class, one-trigger-per-object pattern, recursion prevention, and execution order
From claude-sfdx-iqnpx claudepluginhub bhanu91221/claude-sfdx-iq --plugin claude-sfdx-iqThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Every Salesforce org must follow the one trigger per object rule. The trigger itself contains zero logic; it delegates to a handler class that extends a base TriggerHandler.
public virtual class TriggerHandler {
// Recursion prevention
private static Set<Id> processedIds = new Set<Id>();
private static Map<String, Boolean> bypassedHandlers = new Map<String, Boolean>();
// Context routing
public void run() {
if (isBypassed()) {
return;
}
switch on Trigger.operationType {
when BEFORE_INSERT { beforeInsert(Trigger.new); }
when BEFORE_UPDATE { beforeUpdate(Trigger.new, Trigger.oldMap); }
when BEFORE_DELETE { beforeDelete(Trigger.old, Trigger.oldMap); }
when AFTER_INSERT { afterInsert(Trigger.new, Trigger.newMap); }
when AFTER_UPDATE { afterUpdate(Trigger.new, Trigger.oldMap); }
when AFTER_DELETE { afterDelete(Trigger.old, Trigger.oldMap); }
when AFTER_UNDELETE { afterUndelete(Trigger.new, Trigger.newMap); }
}
}
// Override these in subclasses
protected virtual void beforeInsert(List<SObject> newRecords) { }
protected virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) { }
protected virtual void beforeDelete(List<SObject> oldRecords, Map<Id, SObject> oldMap) { }
protected virtual void afterInsert(List<SObject> newRecords, Map<Id, SObject> newMap) { }
protected virtual void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) { }
protected virtual void afterDelete(List<SObject> oldRecords, Map<Id, SObject> oldMap) { }
protected virtual void afterUndelete(List<SObject> newRecords, Map<Id, SObject> newMap) { }
// Recursion prevention
protected Boolean isAlreadyProcessed(Id recordId) {
return processedIds.contains(recordId);
}
protected void markProcessed(Id recordId) {
processedIds.add(recordId);
}
protected void markProcessed(Set<Id> recordIds) {
processedIds.addAll(recordIds);
}
// Bypass mechanism
public static void bypass(String handlerName) {
bypassedHandlers.put(handlerName, true);
}
public static void clearBypass(String handlerName) {
bypassedHandlers.remove(handlerName);
}
public static void clearAllBypasses() {
bypassedHandlers.clear();
}
private Boolean isBypassed() {
return bypassedHandlers.get(getHandlerName()) == true;
}
private String getHandlerName() {
return String.valueOf(this).split(':')[0];
}
}
The trigger itself is a one-liner that delegates to the handler.
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
new AccountTriggerHandler().run();
}
Rules:
public class AccountTriggerHandler extends TriggerHandler {
protected override void beforeInsert(List<SObject> newRecords) {
List<Account> accounts = (List<Account>) newRecords;
setDefaultValues(accounts);
validateRequiredFields(accounts);
}
protected override void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
List<Account> accounts = (List<Account>) newRecords;
Map<Id, Account> oldAccounts = (Map<Id, Account>) oldMap;
validateStatusTransitions(accounts, oldAccounts);
}
protected override void afterInsert(List<SObject> newRecords, Map<Id, SObject> newMap) {
List<Account> accounts = (List<Account>) newRecords;
createDefaultContacts(accounts);
notifyOwners(accounts);
}
protected override void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
List<Account> accounts = (List<Account>) newRecords;
Map<Id, Account> oldAccounts = (Map<Id, Account>) oldMap;
List<Account> statusChanged = filterStatusChanged(accounts, oldAccounts);
if (!statusChanged.isEmpty()) {
processStatusChange(statusChanged, oldAccounts);
}
}
// Private helper methods
private void setDefaultValues(List<Account> accounts) {
for (Account acc : accounts) {
if (String.isBlank(acc.BillingCountry)) {
acc.BillingCountry = 'US';
}
if (acc.Status__c == null) {
acc.Status__c = 'New';
}
}
}
private void validateRequiredFields(List<Account> accounts) {
for (Account acc : accounts) {
if (String.isBlank(acc.Name)) {
acc.Name.addError('Account Name is required.');
}
}
}
private void validateStatusTransitions(
List<Account> accounts, Map<Id, Account> oldMap
) {
for (Account acc : accounts) {
Account oldAcc = oldMap.get(acc.Id);
if (oldAcc.Status__c == 'Closed' && acc.Status__c != 'Closed') {
acc.addError('Cannot reopen a closed account.');
}
}
}
private List<Account> filterStatusChanged(
List<Account> accounts, Map<Id, Account> oldMap
) {
List<Account> changed = new List<Account>();
for (Account acc : accounts) {
if (acc.Status__c != oldMap.get(acc.Id).Status__c) {
changed.add(acc);
}
}
return changed;
}
private void createDefaultContacts(List<Account> accounts) {
List<Contact> contacts = new List<Contact>();
for (Account acc : accounts) {
contacts.add(new Contact(
FirstName = 'Primary',
LastName = 'Contact',
AccountId = acc.Id
));
}
insert contacts;
}
private void notifyOwners(List<Account> accounts) {
// Send notifications asynchronously
Set<Id> ownerIds = new Set<Id>();
for (Account acc : accounts) {
ownerIds.add(acc.OwnerId);
}
AccountNotificationService.notifyAsync(ownerIds);
}
private void processStatusChange(
List<Account> accounts, Map<Id, Account> oldMap
) {
// Business logic for status changes
}
}
Triggers can fire recursively when after-trigger DML causes the same trigger to re-fire. Use the processedIds set to prevent infinite loops.
protected override void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
List<Account> toProcess = new List<Account>();
for (Account acc : (List<Account>) newRecords) {
if (!isAlreadyProcessed(acc.Id)) {
toProcess.add(acc);
markProcessed(acc.Id);
}
}
if (!toProcess.isEmpty()) {
// This DML may re-fire the trigger, but processed IDs will be skipped
updateRelatedRecords(toProcess);
}
}
Important: The static Set<Id> persists for the entire transaction. If a record legitimately needs to be processed twice (e.g., workflow field update causes re-entry), use a more granular approach:
private static Map<Id, Integer> executionCount = new Map<Id, Integer>();
private static final Integer MAX_EXECUTIONS = 2;
protected Boolean shouldProcess(Id recordId) {
Integer count = executionCount.get(recordId);
if (count == null) {
count = 0;
}
if (count >= MAX_EXECUTIONS) {
return false;
}
executionCount.put(recordId, count + 1);
return true;
}
Allow code to temporarily skip trigger logic. Useful in data migrations, batch operations, or when one handler calls another.
// In a data migration batch
public void execute(Database.BatchableContext bc, List<Account> scope) {
TriggerHandler.bypass('AccountTriggerHandler');
// DML here will not fire Account trigger logic
update scope;
TriggerHandler.clearBypass('AccountTriggerHandler');
}
Custom Setting approach for admin-controlled bypasses:
private Boolean isBypassed() {
// Check static bypass
if (bypassedHandlers.get(getHandlerName()) == true) {
return true;
}
// Check custom setting for current user
Trigger_Bypass__c setting = Trigger_Bypass__c.getInstance();
return setting != null && setting.Bypass_All__c;
}
Understanding the Salesforce order of execution is essential for debugging.
Key implications:
Trigger.new directly@isTest
private class AccountTriggerHandlerTest {
@TestSetup
static void setupData() {
// Create test data without triggering handler logic
// (or let the trigger fire and verify setup state)
}
@isTest
static void beforeInsert_MissingCountry_ShouldDefaultToUS() {
Account acc = new Account(Name = 'Test Account');
Test.startTest();
insert acc;
Test.stopTest();
Account result = [SELECT BillingCountry FROM Account WHERE Id = :acc.Id];
System.assertEquals('US', result.BillingCountry,
'BillingCountry should default to US');
}
@isTest
static void afterInsert_NewAccount_ShouldCreateDefaultContact() {
Account acc = new Account(Name = 'Test Account');
Test.startTest();
insert acc;
Test.stopTest();
List<Contact> contacts = [
SELECT FirstName, LastName
FROM Contact
WHERE AccountId = :acc.Id
];
System.assertEquals(1, contacts.size(), 'Should create one default contact');
}
@isTest
static void bulkInsert_200Records_ShouldNotExceedLimits() {
List<Account> accounts = TestDataFactory.createAccounts(200);
Test.startTest();
insert accounts;
Test.stopTest();
System.assertEquals(200, [SELECT COUNT() FROM Account],
'All 200 accounts should be inserted');
System.assertEquals(200, [SELECT COUNT() FROM Contact],
'Should create 200 default contacts');
}
@isTest
static void bypass_ShouldSkipHandlerLogic() {
TriggerHandler.bypass('AccountTriggerHandler');
Account acc = new Account(Name = 'Test Account');
insert acc;
TriggerHandler.clearBypass('AccountTriggerHandler');
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
System.assertEquals(0, contacts.size(),
'Bypassed handler should not create default contact');
}
}
List<SObject>, never single records.