Last active
June 11, 2020 15:45
-
-
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!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
trigger OpportunityTrigger on Opportunity ( | |
before insert, after insert, | |
before update, after update, | |
before delete, after delete) { | |
//trigger body | |
new OpportunityTriggerHandler().run(); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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