Skip to content

Instantly share code, notes, and snippets.

@KorbenC
Last active June 11, 2020 15:45
Show Gist options
  • Save KorbenC/beddddb7ed2de01c150e8c56f703acd9 to your computer and use it in GitHub Desktop.
Save KorbenC/beddddb7ed2de01c150e8c56f703acd9 to your computer and use it in GitHub Desktop.
One Trigger Per Object Pattern using context specific handler methods and logic-less triggers. Bonus features such as Routing Abstractions, Recursion Detection and Prevention, and Centralize Enable/Disable of Triggers!
trigger OpportunityTrigger on Opportunity (
before insert, after insert,
before update, after update,
before delete, after delete) {
//trigger body
new OpportunityTriggerHandler().run();
}
public class OpportunityTriggerHandler extends TriggerHandler {
public OpportunityTriggerHandler() {
/* recursion protection */
//this.setMaxLoopCount(1);
}
/* context overrides */
protected void override beforeUpdate() {
setLostOppsToZero();
}
public void override afterUpdate() {
List opps = [SELECT Id FROM Opportunity WHERE Id IN :Trigger.newMap.keySet()];
update opps; // this will throw after this update
}
protected void override beforeDelete() {
doSomeStuffBeforeDelete();
}
/* private methods */
private void setLostOppsToZero(List) {
for(Opportunity o : (List<Opportunity>) Trigger.new) {
if(o.StageName == 'Closed Lost' && o.Amount > 0) {
o.Amount = 0;
}
}
}
}
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 {}
}
@isTest
private class TriggerHandler_Test {
private static final String TRIGGER_CONTEXT_ERROR = 'Trigger handler called outside of Trigger execution';
private static String lastMethodCalled;
private static TriggerHandler_Test.TestHandler handler;
static {
handler = new TriggerHandler_Test.TestHandler();
// override its internal trigger detection
handler.isTriggerExecuting = true;
}
/***************************************
* unit tests
***************************************/
// contexts tests
@isTest
static void testBeforeInsert() {
beforeInsertMode();
handler.run();
System.assertEquals('beforeInsert', lastMethodCalled, 'last method should be beforeInsert');
}
@isTest
static void testBeforeUpdate() {
beforeUpdateMode();
handler.run();
System.assertEquals('beforeUpdate', lastMethodCalled, 'last method should be beforeUpdate');
}
@isTest
static void testBeforeDelete() {
beforeDeleteMode();
handler.run();
System.assertEquals('beforeDelete', lastMethodCalled, 'last method should be beforeDelete');
}
@isTest
static void testAfterInsert() {
afterInsertMode();
handler.run();
System.assertEquals('afterInsert', lastMethodCalled, 'last method should be afterInsert');
}
@isTest
static void testAfterUpdate() {
afterUpdateMode();
handler.run();
System.assertEquals('afterUpdate', lastMethodCalled, 'last method should be afterUpdate');
}
@isTest
static void testAfterDelete() {
afterDeleteMode();
handler.run();
System.assertEquals('afterDelete', lastMethodCalled, 'last method should be afterDelete');
}
@isTest
static void testAfterUndelete() {
afterUndeleteMode();
handler.run();
System.assertEquals('afterUndelete', lastMethodCalled, 'last method should be afterUndelete');
}
@isTest
static void testNonTriggerContext() {
try{
handler.run();
System.assert(false, 'the handler ran but should have thrown');
} catch(TriggerHandler.TriggerHandlerException te) {
System.assertEquals(TRIGGER_CONTEXT_ERROR, te.getMessage(), 'the exception message should match');
} catch(Exception e) {
System.assert(false, 'the exception thrown was not expected: ' + e.getTypeName() + ': ' + e.getMessage());
}
}
// test bypass api
@isTest
static void testBypassAPI() {
afterUpdateMode();
// test a bypass and run handler
TriggerHandler.bypass('TestHandler');
handler.run();
System.assertEquals(null, lastMethodCalled, 'last method should be null when bypassed');
System.assertEquals(true, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
// clear that bypass and run handler
TriggerHandler.clearBypass('TestHandler');
handler.run();
System.assertEquals('afterUpdate', lastMethodCalled, 'last method called should be afterUpdate');
System.assertEquals(false, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
// test a re-bypass and run handler
TriggerHandler.bypass('TestHandler');
handler.run();
System.assertEquals(null, lastMethodCalled, 'last method should be null when bypassed');
System.assertEquals(true, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
// clear all bypasses and run handler
TriggerHandler.clearAllBypasses();
handler.run();
System.assertEquals('afterUpdate', lastMethodCalled, 'last method called should be afterUpdate');
System.assertEquals(false, TriggerHandler.isBypassed('TestHandler'), 'test handler should be bypassed');
resetTest();
}
// instance method tests
@isTest
static void testLoopCount() {
beforeInsertMode();
// set the max loops to 2
handler.setMaxLoopCount(2);
// run the handler twice
handler.run();
handler.run();
// clear the tests
resetTest();
try {
// try running it. This should exceed the limit.
handler.run();
System.assert(false, 'the handler should throw on the 3rd run when maxloopcount is 3');
} catch(TriggerHandler.TriggerHandlerException te) {
// we're expecting to get here
System.assertEquals(null, lastMethodCalled, 'last method should be null');
} catch(Exception e) {
System.assert(false, 'the exception thrown was not expected: ' + e.getTypeName() + ': ' + e.getMessage());
}
// clear the tests
resetTest();
// now clear the loop count
handler.clearMaxLoopCount();
try {
// re-run the handler. We shouldn't throw now.
handler.run();
System.assertEquals('beforeInsert', lastMethodCalled, 'last method should be beforeInsert');
} catch(TriggerHandler.TriggerHandlerException te) {
System.assert(false, 'running the handler after clearing the loop count should not throw');
} catch(Exception e) {
System.assert(false, 'the exception thrown was not expected: ' + e.getTypeName() + ': ' + e.getMessage());
}
}
@isTest
static void testLoopCountClass() {
TriggerHandler.LoopCount lc = new TriggerHandler.LoopCount();
System.assertEquals(5, lc.getMax(), 'max should be five on init');
System.assertEquals(0, lc.getCount(), 'count should be zero on init');
lc.increment();
System.assertEquals(1, lc.getCount(), 'count should be 1');
System.assertEquals(false, lc.exceeded(), 'should not be exceeded with count of 1');
lc.increment();
lc.increment();
lc.increment();
lc.increment();
System.assertEquals(5, lc.getCount(), 'count should be 5');
System.assertEquals(false, lc.exceeded(), 'should not be exceeded with count of 5');
lc.increment();
System.assertEquals(6, lc.getCount(), 'count should be 6');
System.assertEquals(true, lc.exceeded(), 'should not be exceeded with count of 6');
}
// private method tests
@isTest
static void testGetHandlerName() {
System.assertEquals('TestHandler', handler.getHandlerName(), 'handler name should match class name');
}
// test virtual methods
@isTest
static void testVirtualMethods() {
TriggerHandler h = new TriggerHandler();
h.beforeInsert();
h.beforeUpdate();
h.beforeDelete();
h.afterInsert();
h.afterUpdate();
h.afterDelete();
h.afterUndelete();
}
/***************************************
* testing utilities
***************************************/
private static void resetTest() {
lastMethodCalled = null;
}
// modes for testing
private static void beforeInsertMode() {
handler.setTriggerContext('before insert', true);
}
private static void beforeUpdateMode() {
handler.setTriggerContext('before update', true);
}
private static void beforeDeleteMode() {
handler.setTriggerContext('before delete', true);
}
private static void afterInsertMode() {
handler.setTriggerContext('after insert', true);
}
private static void afterUpdateMode() {
handler.setTriggerContext('after update', true);
}
private static void afterDeleteMode() {
handler.setTriggerContext('after delete', true);
}
private static void afterUndeleteMode() {
handler.setTriggerContext('after undelete', true);
}
// test implementation of the TriggerHandler
private class TestHandler extends TriggerHandler {
public override void beforeInsert() {
TriggerHandler_Test.lastMethodCalled = 'beforeInsert';
}
public override void beforeUpdate() {
TriggerHandler_Test.lastMethodCalled = 'beforeUpdate';
}
public override void beforeDelete() {
TriggerHandler_Test.lastMethodCalled = 'beforeDelete';
}
public override void afterInsert() {
TriggerHandler_Test.lastMethodCalled = 'afterInsert';
}
public override void afterUpdate() {
TriggerHandler_Test.lastMethodCalled = 'afterUpdate';
}
public override void afterDelete() {
TriggerHandler_Test.lastMethodCalled = 'afterDelete';
}
public override void afterUndelete() {
TriggerHandler_Test.lastMethodCalled = 'afterUndelete';
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment