Organizing Apex Trigger Logic with the Handler Pattern: A Practical Guide
Move trigger logic into a handler class to stop recursion, enable isolated unit tests, and keep bulk processing safe. This guide shows a minimal dispatcher, a recursion‑guard using a Set<Id>, and a worked Account example you can run in a scratch org.
29 Jul 2026, 15:11 UTC

The problem: monolithic triggers become fragile
When all trigger logic lives in a single trigger file, three issues appear quickly:
- Recursion – an
after updatethat modifies the same records fires the trigger again, often hitting the "maximum trigger depth exceeded" limit. - Testing overhead – end‑to‑end tests must perform DML to exercise a single branch, inflating run time and making isolated unit tests impossible.
- Governor‑limit blind spots – bulk‑unsafe code (single‑record queries, DML inside loops) hides until a 200‑record batch hits the org.
Takeaway: Move every trigger event into a dedicated handler class. The trigger becomes a thin dispatcher; the handler owns the business logic, recursion guard, and bulk‑safe implementation.
Core components of the pattern
1. Trigger dispatcher
trigger AccountTrigger on Account (
before insert, after insert,
before update, after update,
before delete, after delete,
after undelete
) {
AccountTriggerHandler.run(Trigger.new, Trigger.oldMap, Trigger.operationType);
}
The dispatcher forwards the standard trigger context variables to a static entry point. No logic lives here.
2. Handler class with explicit event methods
public with sharing class AccountTriggerHandler {
// Recursion guard – per‑record‑id set avoids over‑suppression
private static Set processedIds = new Set();
public static void run(List newList, Map oldMap, System.TriggerOperation op) {
switch on op {
when BEFORE_INSERT { onBeforeInsert(newList); }
when AFTER_INSERT { onAfterInsert(newList); }
when BEFORE_UPDATE { onBeforeUpdate(newList, oldMap); }
when AFTER_UPDATE { onAfterUpdate(newList, oldMap); }
when BEFORE_DELETE { onBeforeDelete(newList); }
when AFTER_DELETE { onAfterDelete(newList); }
when AFTER_UNDELETE { onAfterUndelete(newList); }
}
}
// Example: enforce a naming convention before insert
private static void onBeforeInsert(List newAccounts) {
for (Account a : newAccounts) {
if (a.Name != null && !a.Name.matches('^[A-Z].*')) {
a.addError('Account name must start with an uppercase letter.');
}
}
}
// Example: fire a platform event after insert, bulk‑safe
private static void onAfterInsert(List newAccounts) {
List events = new List();
for (Account a : newAccounts) {
if (processedIds.add(a.Id)) { // guard
events.add(new Account_Event__e(AccountId__c = a.Id, ChangeType__c = 'CREATE'));
}
}
if (!events.isEmpty()) {
EventBus.publish(events);
}
}
// Remaining event methods omitted for brevity
}
Key points:
- Each event maps to a clearly named method (
onBeforeInsert,onAfterUpdate, …). - Methods receive
List<SObject>– bulk‑safe by default. - The recursion guard uses a
Set<Id>so legitimate downstream updates on different records still run.
Worked example: validation + async notification
Assume we need two behaviours on Account:
- Before insert – reject names not starting with an uppercase letter.
- After insert – publish a platform event for an external integration.
The handler above already implements both. To verify the design without deploying a full test suite, run this anonymous Apex in a scratch org (API v60.0):
// Run in Developer Console or `sfdx force:apex:execute -f script.apex`
List batch = new List();
for (Integer i = 0; i < 200; i++) {
batch.add(new Account(Name = 'Test' + i));
}
insert batch; // Should succeed, 200 platform events published
System.debug('Inserted ' + batch.size() + ' accounts');
Where to run: Salesforce CLI (`sfdx force:apex:execute`) against a scratch org or sandbox where the trigger and handler are deployed. Permissions: “Modify All Data” or “Create” on Account, “Author Apex”. Placeholders: replace the org alias if you use `-u my-scratch`. Expected check: Debug log shows a single execution of onBeforeInsert and onAfterInsert; no "maximum trigger depth exceeded" error. Risk: If a validation rule also rejects the name, the insert rolls back – ensure the test data matches the rule.
Trade‑offs and limitations
| Concern | Impact | Mitigation |
|---|---|---|
| Over‑engineering for tiny orgs | Extra classes, indirection, longer onboarding | Start with a single handler per object; adopt a framework (fflib, Trigger Action Framework) only when multiple handlers or cross‑object coordination appear. |
| Static Boolean recursion guard | Silently suppresses legitimate updates on the same record later in the transaction | Use a Set<Id> or a counter with a configurable threshold (e.g., max 2 passes per Id). |
| Debug‑log indirection | Stack traces show handler method names, not the original trigger line | Adopt a naming convention (AccountTriggerHandler.onAfterInsert) and enable “Show Apex Debug Logs” for the handler class. |
| Managed‑package extensibility | Subscribers cannot override private handler methods | Expose virtual methods or drive behaviour via Custom Metadata Types. |
Governor limits (SOQL 100, DML 150, CPU 10 s) still apply at the transaction level; the pattern only organizes code – bulkified queries and selective indexing remain mandatory.
Actionable next steps
- Create a scratch org (`sfdx force:org:create -f config/project-scratch-def.json -a handler-demo`).
- Deploy the trigger and handler (`sfdx force:source:deploy -p force-app/main/default/triggers/AccountTrigger.trigger -p force-app/main/default/classes/AccountTriggerHandler.cls -u handler-demo`).
- Run the bulk insert script from the worked example and confirm a single pass in the debug log.
- Write a unit test that calls the handler directly:
@isTest private class AccountTriggerHandlerTest { @isTest static void validatesNameFormat() { List accts = new List{ new Account(Name = 'lowercase') }; Test.startTest(); AccountTriggerHandler.onBeforeInsert(accts); Test.stopTest(); System.assert(accts[0].getErrors().size() > 0, 'Expected validation error'); } } - Add the PMD rule
ApexTriggerShouldNotHaveLogicInTriggerto your CI pipeline to enforce the pattern on future changes.
After these steps you have a verifiable, testable trigger architecture that scales with the org’s complexity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.