Apex Triggers · Lesson 26 of 41
Trigger Handler Pattern: One Trigger per Object
Trigger code that lives in the trigger body works for a while. Then a second developer adds a second trigger on the same object, nobody can say which runs first (the platform doesn't guarantee an order), logic can't be unit-tested in isolation, and a data fix can't be run without the triggers firing. The trigger handler pattern fixes all of that, and it's what employers expect to see.
In this lesson
- Why one trigger per object
- A small reusable handler base class
- A real handler for Contact
- Stopping recursion
- Bypassing triggers for data loads
The layers
- Triggerone per object, one line: new ContactTriggerHandler().run();
- Handlerroutes each event to a method: beforeInsert, afterUpdate…
- Services / domain classesthe actual business logic, bulkified and unit-testable
The base class
// TriggerHandler.cls
public virtual class TriggerHandler {
private static Set<String> bypassed = new Set<String>();
public static void bypass(String handlerName) { bypassed.add(handlerName); }
public static void clearBypass(String handlerName) { bypassed.remove(handlerName); }
public void run() {
String name = String.valueOf(this).substringBefore(':'); // the handler's class name
if (!Trigger.isExecuting || bypassed.contains(name)) return;
switch on Trigger.operationType {
when BEFORE_INSERT { beforeInsert(Trigger.new); }
when BEFORE_UPDATE { beforeUpdate(Trigger.new, Trigger.oldMap); }
when BEFORE_DELETE { beforeDelete(Trigger.oldMap); }
when AFTER_INSERT { afterInsert(Trigger.newMap); }
when AFTER_UPDATE { afterUpdate(Trigger.newMap, Trigger.oldMap); }
when AFTER_DELETE { afterDelete(Trigger.oldMap); }
when AFTER_UNDELETE { afterUndelete(Trigger.newMap); }
}
}
// subclasses override only what they need
protected virtual void beforeInsert(List<SObject> newRecords) {}
protected virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
protected virtual void beforeDelete(Map<Id, SObject> oldMap) {}
protected virtual void afterInsert(Map<Id, SObject> newMap) {}
protected virtual void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {}
protected virtual void afterDelete(Map<Id, SObject> oldMap) {}
protected virtual void afterUndelete(Map<Id, SObject> newMap) {}
}
Open-source frameworks (Kevin O'Hara's trigger handler, fflib's domain layer, Apex Trigger Actions Framework) follow the same idea with more features. This small version shows every moving part.
The trigger and the handler
// ContactTrigger.trigger
trigger ContactTrigger on Contact (before insert, before update, after insert, after update) {
new ContactTriggerHandler().run();
}
// ContactTriggerHandler.cls
public with sharing class ContactTriggerHandler extends TriggerHandler {
private static Set<Id> processedAfterUpdate = new Set<Id>(); // recursion guard for this transaction
protected override void beforeInsert(List<SObject> newRecords) {
normalise((List<Contact>) newRecords);
}
protected override void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
normalise((List<Contact>) newRecords);
}
protected override void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {
List<Contact> toStamp = new List<Contact>();
for (Contact c : (List<Contact>) newMap.values()) {
if (processedAfterUpdate.contains(c.Id)) continue; // already handled in this transaction
processedAfterUpdate.add(c.Id);
Contact old = (Contact) oldMap.get(c.Id);
if (c.Email != old.Email) {
toStamp.add(new Contact(Id = c.Id, Description = 'Email changed from ' + old.Email));
}
}
System.debug('afterUpdate: ' + newMap.size() + ' record(s), ' + toStamp.size() + ' to stamp');
if (!toStamp.isEmpty()) update toStamp; // fires the trigger again…
}
private static void normalise(List<Contact> contacts) {
for (Contact c : contacts) {
if (c.Email != null) c.Email = c.Email.trim().toLowerCase();
if (c.FirstName != null) c.FirstName = c.FirstName.trim().capitalize();
}
}
}
Contact c = new Contact(FirstName = ' asha ', LastName = 'Verma', Email = ' Asha@Acme.EXAMPLE ');
insert c;
Contact saved = [SELECT FirstName, Email FROM Contact WHERE Id = :c.Id];
System.debug('Normalised: ' + saved.FirstName + ' <' + saved.Email + '>');
c.Email = 'asha.verma@acme.example';
update c;
System.debug([SELECT Description FROM Contact WHERE Id = :c.Id].Description);
Normalised: Asha <asha@acme.example> afterUpdate: 1 record(s), 1 to stamp afterUpdate: 1 record(s), 0 to stamp Email changed from asha@acme.example
The handler's own update toStamp fired the trigger a second time – that's the second "afterUpdate" line. The static processedAfterUpdate set made the second run skip the records it had already handled, so there was no infinite loop and no repeated work. Without the guard, the second run would compare against the already-changed email and could keep re-triggering.
Don't use a single static Boolean for recursion
The popular static Boolean firstRun = true trick breaks with large data loads: the platform calls the trigger once per chunk of 200 records in the same transaction, so a Boolean set by chunk 1 silently skips chunks 2, 3… A set of processed Ids only skips records that were really handled.
Bypassing a handler
Data migrations and some integrations need to save records without running automation. The static bypass switch on the base class makes that a one-liner – and because statics live for one transaction, it can't "stick" for other users:
TriggerHandler.bypass('ContactTriggerHandler');
insert new Contact(FirstName = ' raw ', LastName = 'Import', Email = ' RAW@DATA.EXAMPLE ');
TriggerHandler.clearBypass('ContactTriggerHandler');
Contact raw = [SELECT FirstName, Email FROM Contact WHERE LastName = 'Import'];
System.debug('FirstName saved as "' + raw.FirstName + '" – the handler would have made it "Raw"');
FirstName saved as "raw" – the handler would have made it "Raw"
Notice that the email still came out lower-case and trimmed even with the handler bypassed: Salesforce itself trims text fields and lower-cases email addresses on save. The first name is the visible proof – the handler's capitalisation didn't run.
Production frameworks often add an org-level switch too – a Custom Metadata or hierarchy Custom Setting checked in run() – so admins can disable a handler for a user (the integration user, say) without a deployment.
Practice
Try it yourself
Add before-delete logic to ContactTriggerHandler that prevents deleting contacts whose Title is 'CEO'.
Show answer
Add
before deleteto the trigger's events and override:protected override void beforeDelete(Map<Id, SObject> oldMap) { for (Contact c : (List<Contact>) oldMap.values()) { if (c.Title == 'CEO') c.addError('CEO contacts cannot be deleted.'); } }Why put the logic in a handler instead of the trigger body?
Show answer
Predictable order (one trigger per object), logic you can unit-test and reuse, a single place to add recursion control and bypasses, and much easier maintenance.
Quick check
Why one trigger per object?
The order of several triggers on one object isn't guaranteed.
What's the best recursion guard?
A static Set of processed record Ids – not a single static Boolean.
How long does a static bypass last?
Only for the current transaction.
Summary
- One trigger per object, containing a single call to its handler.
- A base class routes events; handlers override only what they need.
- Guard recursion with a static Set of Ids.
- Provide a bypass for data loads and integrations.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.