Triggers are where a lot of Salesforce technical debt accumulates. A single Account trigger that’s grown to 800 lines of mixed concerns, bypassed by a tangle of static boolean flags, tested only at the integration level - this is the state most mature orgs end up in. A trigger framework doesn’t prevent bad code, but it establishes a structure that makes good code easier and makes the bad code visible.
The One-Trigger-Per-Object Rule
This is the non-negotiable starting point. Salesforce allows multiple triggers on the same object, but trigger execution order within the same event is non-deterministic. If you have three before insert triggers on Opportunity, you cannot rely on them running in any particular order.
The rule: one trigger per object, period. The trigger itself contains no logic - it delegates entirely to a handler.
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
AccountTriggerHandler handler = new AccountTriggerHandler();
handler.run();
}
That’s the entire trigger. No logic, no conditions, no bypass flags. Everything lives in the handler.
Why Trigger Frameworks Exist
Without a framework, every object ends up with its own trigger structure invented by whoever wrote it first. The problems compound:
- No consistent way to bypass triggers in tests (leading to brittle setups)
- No bulkification discipline - logic written for single records breaks in bulk data operations
- No clear place to add cross-cutting concerns (logging, recursion prevention)
- Impossible to disable specific handlers in production without a deployment
A framework gives you a single, understood pattern across all objects.
The TriggerHandler Base Class
The core pattern is a base class with virtual methods for each trigger event:
public virtual class TriggerHandler {
private static Set<String> bypassedHandlers = new Set<String>();
private Boolean isTriggerExecuting;
protected Integer loopCount;
protected Integer maxLoopCount;
public TriggerHandler() {
this.isTriggerExecuting = true;
this.loopCount = 0;
this.maxLoopCount = 5;
}
public void run() {
if (!isTriggerExecuting) return;
if (isBypassed(getHandlerName())) return;
if (loopCount >= maxLoopCount) {
throw new TriggerHandlerException(
getHandlerName() + ' has exceeded its maximum loop count of ' + maxLoopCount
);
}
loopCount++;
switch on Trigger.operationType {
when BEFORE_INSERT { beforeInsert(Trigger.new); }
when BEFORE_UPDATE { beforeUpdate(Trigger.new, Trigger.oldMap); }
when BEFORE_DELETE { beforeDelete(Trigger.old); }
when AFTER_INSERT { afterInsert(Trigger.new); }
when AFTER_UPDATE { afterUpdate(Trigger.new, Trigger.oldMap); }
when AFTER_DELETE { afterDelete(Trigger.old); }
when AFTER_UNDELETE { afterUndelete(Trigger.new); }
}
}
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) {}
protected virtual void afterInsert(List<SObject> newRecords) {}
protected virtual void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
protected virtual void afterDelete(List<SObject> oldRecords) {}
protected virtual void afterUndelete(List<SObject> newRecords) {}
public static void bypass(String handlerName) {
bypassedHandlers.add(handlerName);
}
public static void clearBypass(String handlerName) {
bypassedHandlers.remove(handlerName);
}
public static Boolean isBypassed(String handlerName) {
return bypassedHandlers.contains(handlerName);
}
private String getHandlerName() {
return String.valueOf(this).substring(0, String.valueOf(this).indexOf(':'));
}
public class TriggerHandlerException extends Exception {}
}
Implementing a Handler
Each object gets a concrete handler class that extends the base. Only override the methods you need:
public class AccountTriggerHandler extends TriggerHandler {
private List<Account> newAccounts;
private Map<Id, Account> oldAccountMap;
public AccountTriggerHandler() {
this.newAccounts = (List<Account>) Trigger.new;
this.oldAccountMap = (Map<Id, Account>) Trigger.oldMap;
}
protected override void beforeInsert(List<SObject> newRecords) {
List<Account> accounts = (List<Account>) newRecords;
AccountService.setDefaultValues(accounts);
AccountService.validateRequiredFields(accounts);
}
protected override void afterInsert(List<SObject> newRecords) {
List<Account> accounts = (List<Account>) newRecords;
AccountService.createDefaultContacts(accounts);
AccountService.syncToExternalSystem(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;
// Only process records where the relevant field changed
List<Account> changedAccounts = new List<Account>();
for (Account acc : accounts) {
Account oldAcc = oldAccounts.get(acc.Id);
if (acc.Rating != oldAcc.Rating) {
changedAccounts.add(acc);
}
}
if (!changedAccounts.isEmpty()) {
AccountService.handleRatingChange(changedAccounts, oldAccounts);
}
}
}
Bulkification in Handlers
The handler receives the full Trigger.new list - always. Handlers must be written to process all records in bulk. This means:
- No SOQL inside loops
- Collect all IDs first, query once, build a map, then iterate
- Collect DML operations into lists, execute after the loop
protected override void afterInsert(List<SObject> newRecords) {
List<Account> accounts = (List<Account>) newRecords;
// Collect IDs for a single query
Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
// Single query outside any loop
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
// Build DML list
List<Task> tasksToInsert = new List<Task>();
for (Account acc : accounts) {
List<Contact> contacts = contactsByAccount.get(acc.Id);
if (contacts != null && !contacts.isEmpty()) {
tasksToInsert.add(new Task(
Subject = 'Welcome call',
WhoId = contacts[0].Id,
WhatId = acc.Id,
ActivityDate = Date.today().addDays(3)
));
}
}
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
}
Bypassing Triggers in Tests
The static bypass mechanism makes test setup clean:
@IsTest
static void testAccountWithoutTriggerSideEffects() {
TriggerHandler.bypass('AccountTriggerHandler');
Account acc = new Account(Name = 'Test Account');
insert acc;
TriggerHandler.clearBypass('AccountTriggerHandler');
// Now test specific behavior without interference from
// createDefaultContacts or syncToExternalSystem
}
This is far cleaner than the old AccountTriggerHandler.bypassTrigger = true static boolean pattern, which required knowing the handler class name at the test level.
Custom Framework vs fflib
fflib (the Force.com Enterprise Patterns library) is a comprehensive alternative that includes its own trigger dispatcher, domain layer, and unit of work pattern. It’s well-maintained and battle-tested, but it has a steep learning curve and brings opinions about your entire application architecture, not just triggers.
Build your own framework when:
- You want minimal dependencies
- The team is comfortable with Apex but not fflib
- Your org’s complexity doesn’t justify the full enterprise pattern stack
Use fflib when:
- You’re building a large ISV product
- You want the full Domain/Selector/Service separation enforced by the framework
- The team has fflib experience and you’re starting from scratch
For most customer orgs, a custom TriggerHandler base class hits the sweet spot. It’s 100 lines of code that solves 80% of the structural problems.