Triggers
Putting logic into your triggers creates un-testable, difficult-to-maintain code.
Best-practice is to move trigger logic into a handler class. This pattern take it a step further moving each each action into an Operation
class of it's own, improving separation of concerns
and test ability.
Opportunity Trigger
trigger OpportunityTrigger on Opportunity (before insert, before update) {
new OpportunityTriggerHandler().run();
}
Example Trigger Handler Example
Notice the Trigger__c
custom setting, this is used to easily enable or disable the trigger.
public without sharing class OpportunityTriggerHandler extends TriggerHandler {
public override void beforeUpdate() {
if (isTriggerEnabled()) {
new OpportunityActionOperation(Trigger.oldMap, Trigger.newMap).execute();
}
}
private Boolean isTriggerEnabled() {
return Triggers__c.getOrgDefaults().Opportunity_Trigger__c;
}
}
Here are all of the methods that you can override. All of the context possibilities are supported.
beforeInsert()
beforeUpdate()
beforeDelete()
afterInsert()
afterUpdate()
afterDelete()
afterUndelete()
Opportunity Trigger Operation Example
Notice the Trigger__c
custom settings, this is used to easily enable or disable this specific operation.
public with sharing class OpportunityActionOperation {
private Map<Id, Opportunity> oldMap;
private Map<Id, Opportunity> newMap;
public OpportunityActionOperation(Map<Id, SObject> oldMap, Map<Id, SObject> newMap) {
this.oldMap = (Map<Id, Opportunity>) oldMap;
this.newMap = (Map<Id, Opportunity>) newMap;
}
public void execute() {
if (IsEnabled()) {
// do something
}
}
private Boolean isEnabled() {
return Triggers__c.getOrgDefaults().Opportunity_Action_Operation__c;
}
}
Trigger Handler base class
public virtual class TriggerHandler {
// static map of handlername, times run() was invoked
private static Map<String, LoopCount> loopCountMap;
private static Set<String> bypassedHandlers;
// the current context of the trigger, overridable in tests
@TestVisible
private TriggerContext context;
// the current context of the trigger, overridable in tests
@TestVisible
private Boolean isTriggerExecuting;
// static initialization
static {
loopCountMap = new Map<String, LoopCount>();
bypassedHandlers = new Set<String>();
}
// constructor
public TriggerHandler() {
this.setTriggerContext();
}
/***************************************
* public instance methods
***************************************/
// main method that will be called during execution
public void run() {
if(!validateRun()) return;
addToLoopCount();
// dispatch to the correct handler method
if(this.context == TriggerContext.BEFORE_INSERT) {
this.beforeInsert();
} else if(this.context == TriggerContext.BEFORE_UPDATE) {
this.beforeUpdate();
} else if(this.context == TriggerContext.BEFORE_DELETE) {
this.beforeDelete();
} else if(this.context == TriggerContext.AFTER_INSERT) {
this.afterInsert();
} else if(this.context == TriggerContext.AFTER_UPDATE) {
this.afterUpdate();
} else if(this.context == TriggerContext.AFTER_DELETE) {
this.afterDelete();
} else if(this.context == TriggerContext.AFTER_UNDELETE) {
this.afterUndelete();
}
}
public void setMaxLoopCount(Integer max) {
String handlerName = getHandlerName();
if(!TriggerHandler.loopCountMap.containsKey(handlerName)) {
TriggerHandler.loopCountMap.put(handlerName, new LoopCount(max));
} else {
TriggerHandler.loopCountMap.get(handlerName).setMax(max);
}
}
public void clearMaxLoopCount() {
this.setMaxLoopCount(-1);
}
/***************************************
* public static methods
***************************************/
public static void bypass(String handlerName) {
TriggerHandler.bypassedHandlers.add(handlerName);
}
public static void clearBypass(String handlerName) {
TriggerHandler.bypassedHandlers.remove(handlerName);
}
public static Boolean isBypassed(String handlerName) {
return TriggerHandler.bypassedHandlers.contains(handlerName);
}
public static void clearAllBypasses() {
TriggerHandler.bypassedHandlers.clear();
}
/***************************************
* private instancemethods
***************************************/
@TestVisible
private void setTriggerContext() {
this.setTriggerContext(null, false);
}
@TestVisible
private void setTriggerContext(String ctx, Boolean testMode) {
if(!Trigger.isExecuting && !testMode) {
this.isTriggerExecuting = false;
return;
} else {
this.isTriggerExecuting = true;
}
if((Trigger.isExecuting && Trigger.isBefore && Trigger.isInsert) ||
(ctx != null && ctx == 'before insert')) {
this.context = TriggerContext.BEFORE_INSERT;
} else if((Trigger.isExecuting && Trigger.isBefore && Trigger.isUpdate) ||
(ctx != null && ctx == 'before update')){
this.context = TriggerContext.BEFORE_UPDATE;
} else if((Trigger.isExecuting && Trigger.isBefore && Trigger.isDelete) ||
(ctx != null && ctx == 'before delete')) {
this.context = TriggerContext.BEFORE_DELETE;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isInsert) ||
(ctx != null && ctx == 'after insert')) {
this.context = TriggerContext.AFTER_INSERT;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isUpdate) ||
(ctx != null && ctx == 'after update')) {
this.context = TriggerContext.AFTER_UPDATE;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isDelete) ||
(ctx != null && ctx == 'after delete')) {
this.context = TriggerContext.AFTER_DELETE;
} else if((Trigger.isExecuting && Trigger.isAfter && Trigger.isUndelete) ||
(ctx != null && ctx == 'after undelete')) {
this.context = TriggerContext.AFTER_UNDELETE;
}
}
// increment the loop count
@TestVisible
private void addToLoopCount() {
String handlerName = getHandlerName();
if(TriggerHandler.loopCountMap.containsKey(handlerName)) {
Boolean exceeded = TriggerHandler.loopCountMap.get(handlerName).increment();
if(exceeded) {
Integer max = TriggerHandler.loopCountMap.get(handlerName).max;
throw new TriggerHandlerException('Maximum loop count of ' + String.valueOf(max) + ' reached in ' + handlerName);
}
}
}
// make sure this trigger should continue to run
@TestVisible
private Boolean validateRun() {
if(!this.isTriggerExecuting || this.context == null) {
throw new TriggerHandlerException('Trigger handler called outside of Trigger execution');
}
if(TriggerHandler.bypassedHandlers.contains(getHandlerName())) {
return false;
}
return true;
}
@TestVisible
private String getHandlerName() {
return String.valueOf(this).substring(0,String.valueOf(this).indexOf(':'));
}
/***************************************
* context methods
***************************************/
// context-specific methods for override
@TestVisible
protected virtual void beforeInsert(){}
@TestVisible
protected virtual void beforeUpdate(){}
@TestVisible
protected virtual void beforeDelete(){}
@TestVisible
protected virtual void afterInsert(){}
@TestVisible
protected virtual void afterUpdate(){}
@TestVisible
protected virtual void afterDelete(){}
@TestVisible
protected virtual void afterUndelete(){}
/***************************************
* inner classes
***************************************/
// inner class for managing the loop count per handler
@TestVisible
private class LoopCount {
private Integer max;
private Integer count;
public LoopCount() {
this.max = 5;
this.count = 0;
}
public LoopCount(Integer max) {
this.max = max;
this.count = 0;
}
public Boolean increment() {
this.count++;
return this.exceeded();
}
public Boolean exceeded() {
if(this.max < 0) return false;
if(this.count > this.max) {
return true;
}
return false;
}
public Integer getMax() {
return this.max;
}
public Integer getCount() {
return this.count;
}
public void setMax(Integer max) {
this.max = max;
}
}
// possible trigger contexts
@TestVisible
private enum TriggerContext {
BEFORE_INSERT, BEFORE_UPDATE, BEFORE_DELETE,
AFTER_INSERT, AFTER_UPDATE, AFTER_DELETE,
AFTER_UNDELETE
}
// exception class
public class TriggerHandlerException extends Exception {}
}
Last updated