APEX Triggers using pattern EDA3
Introduction
We all have triggers to create and most of us know some of the best practices around them. But creating those triggers consistently, while following best practices and ensuring proper error handling and DML handling is a challenge. Especially when bringing on vendors or contractors to help with the work.
Those of you who have attended DreamForce or watched their videos may have seen a pattern called EDA (Event, Dispatch, Action). This pattern went a long way toward making the triggers more scalable and maintainable. But there were other issues still involved such as:
- Consistent error handling.
- Consistent DML handling including multiple events.
- Being able to do multiple updates to the same record in different methods.
Let's see how we can reduce those issues, while also making the triggers truly robust and able to handle changes in the future.
What is EDA3?
EDA3 refers to version 3 of the EDA pattern (Event, Dispatch, Action) for triggers. It can be adapted somewhat for controllers, but we'll focus on triggers for now.
Imagine you are home and you notice water all over your bathroom. Wow! Time to call the plumber. You do so (that's the Event), and they "Dispatch" a technician to your home. Once there, they perform some repairs (Action), and then maybe they'll order a follow-up or parts for a more permanent fix (DML). If it was warranty work, maybe they'll see something that invalidates your warranty (returning an error to the trigger).
This pattern follows that situation and more. It may seem quite complicated - maybe needlessly so - but, in the end, it will handle so many details for you and let you concentrate on the outcome and not the details around DML and error handling. Think of it this way - when you use a MAP in Salesforce, do you need to handle how things are stored in memory, retrieved, managed? No, that's a class in Salesforce. Now, you'll have your own class to handle things around DML and error handling. And, as an added benefit, it will reduce the overall complexity and size of code in development. Let's see...
Trigger
This is your "event". First, let's make sure to run on every event. The trigger should never have to be changed after this. Let Dispatcher decide if anything really needs to be done.
trigger myObject on myObject__c (before insert, before update, after insert, after update, before delete, after delete) {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 10/21/2020 Perform trigger operations for myObject
*/
myObject_Helper.ResetRecursionFlags(
new myObject_Dispatcher().Trigger_Handler(
(trigger.isInsert || trigger.isUnDelete ? null : trigger.oldMap),
(trigger.isDelete ? null : trigger.newMap),
trigger.new, trigger.operationType), trigger.operationType);
}// END myObject
Wow! One executable line? That's right because that's the first "best practice" for triggers. No "logic" in your trigger. Can't really get simpler than this, right?
OK, before we discuss what the Dispatcher and Helper referenced are doing, let's look at the arguments. We are simply passing to a method (Trigger_Handler) a MAP of old values (null if it is an Insert), a MAP of new values (null if it is a Delete), the LIST of new values, and what the operation was. This is another best practice. Try to avoid having more than 4 arguments to a method.
So far, so good. But what is this Dispatcher we called, and why are we giving the answer as an argument to another method (ResetRecursionFlags)? Patience grasshopper!
Dispatcher
This, of course, is your "Dispatch". Let's look at an abbreviated Dispatcher.
public inherited sharing class myObject_Dispatcher EXTENDS Trigger_Dispatcher {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 09/29/2020 Sample EDA 3.1
*/
public static myObject_Services services {
get {
if (services == null) services = new myObject_Services();
return services;
} // END get
set {}
}// END services
public override void Action(MAP<ID, sObject> triggerOldMAP, MAP<ID, sObject> triggerNewMAP, LIST<sObject> triggerNewLIST, System.TriggerOperation operation) {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 09/29/2020 Sample EDA 3.1
*/
if(Utility.canTrigger('myObjectTrigger')) {
if((bIsInsert || bIsUpdate) && bIsAfter) {
errorMap.putAll(services.maintainAudit(helper,(MAP<ID, myObject__c>) triggerOldMAP, (MAP<ID, myObject__c>) triggerNewMAP,(LIST<myObject__c>) triggerNewLIST,operation));
}// END if((bIsInsert || bIsUpdate) && bIsAfter) {
myObject_Helper.setRecursionFlags(operation);
}// END if(Utility.canTrigger('myObjectTrigger'))
}//END Action Method
}//END CLASS myObject_Dispatcher
OK, not a lot of lines, but there is a lot actually going on here. AND, the ability to add functionality in the future is huge!
Let's start within the body first. You'll see a single Action method. But we don't call that method from the Trigger! That's because it's called from the Interited Class on the class definition; Trigger_Dispatcher. More on that later.
Within that Action method, you'll see a check for "canTrigger". That is simply a Utility method that checks a custom setting to see if this specific trigger exists in the Custom Setting and, if so, is it enabled. We have a method that allows a global disable, or disabling by User ID and/or Profile ID.
Within that, is a check for the event type. So Dispatcher is doing nothing but determining if the event in question should have APEX automation around it. If so, a single line to the Services method to process that automation, and receive back any locally thrown errors (think Try/Catch, or Validation errors).
Finally, at the end of the block is a call to the Helper class (we'll get there) which sets the recursion flags for this particular event. If it is an After event, it will set a global recursion flag as well. With this, you could add to the first if, "&& !myObject_Helper.bPreventRecursion" or the specific event. More on that later.
We also create a property in this Dispatcher to note the Services class and instantiate it. This is pretty important because you'll note that nothing in the Dispatcher is "Static". It must use an instantiated value.
Also, you are sending a "helper" reference to the class method. But you never declared that. That's also coming from your inherited class. More on that later.
So, wait. If nothing is static, how do we set flags like the recursion and others that are needed? Thank you for asking grasshopper! NOW we will look at the Helper before we look at Services (which also doesn't have any Static within it).
Helper
The helper class simply manages the Static values - in this case, the recursion flags. You could add others here, but consider if they truly need to be Static. If not, leave them in the instantiated Services class.
This Helper is not to be confused with the Trigger_Helper which is what you are actually passing to Services from Dispatch. Rather, this is a Helper class to allow you to manage your recursion flags, and any other static information needed for this run. You should keep your static flags/methods to a minimum wherever possible.
Here is what a Helper class would look like. AND, you can simply copy/paste it for each implementation. Why not inherit it as you do for the Dispatcher? 2 reasons: First, because you want the ability to change the specific trigger outside of the inherited functionality. Yes, you could overload it, but, the second reason is so that you can access these flags from Test classes and set them as needed to test different scenarios within a single test.
public Inherited sharing class myObject_Helper {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 09/29/2020 Sample EDA 3.1
*/
public static boolean bPreventRecursion = false;
public static boolean bPreventAI = false; // Provided for extra capabilities
public static boolean bPreventAU = false; // Provided for extra capabilities
public static boolean bPreventAD = false; // Provided for extra capabilities
public static boolean bPreventBI = false; // Provided for extra capabilities
public static boolean bPreventBU = false; // Provided for extra capabilities
public static boolean bPreventBD = false; // Provided for extra capabilities
public static boolean strTestFlag = ''; // To allow us to "fake" an error for coverage
public static boolean bResetRecursion = true;
public static void resetRecursionFlags(boolean bResetRecursionLocal, System.TriggerOperation operation) {
if (!bResetRecursionLocal) {
bPreventRecursion = false;
bPreventAI = false;
bPreventAU = false;
bPreventAD = false;
bPreventBI = false;
bPreventBU = false;
bPreventBD = false;
bTestFlag = '';
} // END if (!bResetRecursionLocal)
} // END resetRecursionFlags
public static void setRecursionFlags(System.TriggerOperation operation) {
switch on operation {
when BEFORE_INSERT {
bPreventBI = bResetRecursion;
} // END BEFORE_INSERT
when BEFORE_UPDATE {
bPreventBU = bResetRecursion;
} // END BEFORE_UPDATE
when BEFORE_DELETE {
bPreventBD = bResetRecursion;
} // END BEFORE_DELETE
when AFTER_INSERT {
bPreventAI = bResetRecursion;
bPreventRecursion = bResetRecursion;
} // END AFTER_INSERT
when AFTER_UPDATE {
bPreventAU = bResetRecursion;
bPreventRecursion = bResetRecursion;
} // END AFTER_UPDATE
when AFTER_DELETE {
bPreventAD = bResetRecursion;
bPreventRecursion = bResetRecursion;
} // END AFTER_DELETE
} // END switch on operation
} // END setRecursionFlags
} // END myObject_Helper
HERE is where your static flags are! BUT, remember back in the trigger where we called a Helper.resetRecursionFlags method? Here it is! And all it does is check to see if the flag passed was false. If so, reset the recursion flags overriding what you might have done in the setRecursionFlags. Why? Because if a Trigger encounters an error, APEX will retry SUCCESSES up to 3X. And if the recursion is set to bypass, your successes will not "stick".
Finally, let's tie it together with the Services class.
Services
The Services referenced from Dispatcher is your "Action". You CAN have more than one if needed, but I'd try to stay within one just for simplicity.
Let's look at a sample mythical requirement.
public with sharing class myObject_Services {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 09/29/2020 Sample EDA 3.1
*/
public MAP<string, string> maintainAudit(Trigger_Helper helper, MAP<ID, myObject__c> triggerOldMAP, MAP<ID, myObject__c> triggerNewMAP, LIST<myObject__c> triggerNewLIST, System.TriggerOperation operation) {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 09/29/2020 Sample EDA 3.1
*/
MAP<String, String> errorMAP = new MAP<String, String>();
try {
if(operation == System.TriggerOperation.AFTER_INSERT || operation == System.TriggerOperation.AFTER_UPDATE){
Id dataAdminRecordTypeID = Schema.SObjectType.myObject__c.getRecordTypeInfosByName().get('Data Admin').getRecordTypeId();
Id dataAdminProfileID = '00e001234512345;
LIST<string> strAuditLIST = new LIST<string>();
MAP<string, myObject_Audit__c> auditMAP = new MAP<string, myObject_Audit__c>();
for (myObject__c obj : triggerNewLIST) {
strAuditLIST.add(obj.ID);
} // END for (myObject__c obj : triggerNewLIST)
for (myObject_Audit__c auditItem : [select ID from myObject_Audit__c where myObject__c in :strAuditLIST]) {
auditMAP.put(auditItem.myObject__c, auditItem);
} // END for (myObject_Audit__c auditItem : [select ID from myObject_Audit__c where myObject__c in :strAuditLIST])
// When Field__c = 'A', insert a record into the object. When 'B', update the object. When 'C', delete the object.
integer nLoop = 0;
for (myObject__c obj : triggerNewLIST) {
myObject_Audit__c auditItem = new myObject_Audit__c();
if (auditMAP.containsKey(obj.ID)) auditItem = auditMAP.get(obj.ID);
auditItem = (helper.sObjUpdMAP.containsKey(auditItem.ID) ? (Apttus__Apts_Agreement__c) helper.sObjUpdMAP.get(auditItem.ID) : auditItem);
if(obj.Field__c == 'A') {
helper.RecordDML(Trigger_Helper.DMLType.InsertDML, nLoop, (sObject) new myObject_Audit__c(myObject__c = obj.ID, Status__c = 'New'), obj.ID);
} // END if(obj.Field__c == 'A')
else if(obj.Field__c == 'B' && auditMAP.containsKey(obj.ID)) {
auditItem = auditMAP.get(obj.ID);
auditItem.Status__c = 'Updated';
helper.RecordDML(Trigger_Helper.DMLType.UpdateDML, auditItem.ID, (sObject) auditItem, obj.ID);
} // END if(obj.Field__c == 'B')
else if(obj.Field__c == 'C') {
auditItem = auditMAP.get(obj.ID);
helper.RecordDML(Trigger_Helper.DMLType.DeleteDML, auditItem.ID, (sObject) auditItem, obj.ID);
} // END if(obj.Field__c == 'C')
} // END for (myObject__c obj : triggerNewLIST)
}// END if(operation == System.TriggerOperation.AFTER_INSERT || operation == System.TriggerOperation.AFTER_UPDATE)
}//END Try Block
Catch (Exception ex){
for (myObject__c obj : triggerNewLIST) {
errorMAP.put(obj.ID, 'Exception noted: ' + ex.getMessage()[0];
} // END for (myObject__c obj : triggerNewLIST)
}// END CATCH BLOCK
return errorMAP;
}// END maintainAudit
}//END myObject_Services Class
Let's not worry too much about what this is doing, or how elegant it is. But there are a few points to call out that are key to making this extremely dependable, flexible, and scalable. (I know, a lot of "able's" in there, huh?)
Of more importance here are a few things. First, take a look at the method declaration (maintainAudit). Notice it is NOT static. That's because it is accessed through the instantiation in the Dispatcher. Second, notice it returns a MAP<string, string>. This represents any errors you want to return from the method, but NOT from the DML's. Those will be handled FOR YOU! For example, look at the Catch block. We can return an error through the errorMAP and it gets fed to the Dispatcher. ALL errors, whether from the errorMAP or DML will be consolidated by the Dispatcher and reported for each item in the trigger LIST.
Also, notice how we set "auditItem". First, we create a new representation (so we don't impact any others in the batch since APEX is a "reference" language. Then we decide what to set it to. If we find that it is already in the Update MAP, we get that representation, and if not use the New one. Huh, how could it be in the Update MAP already? Imagine if you created other methods that occur before this one later on. As long as you follow this method of handling the update, all updates will be consolidated into a single one for the ID and you won't receive a "duplicates in batch" error.
Finally, most important, notice the helper.RecordDML lines. What is THAT?! OK, first, the helper is the reference you passed in. Again, we'll cover that soon. Suffice to say there is a RecordDML method in there. As a matter of fact, there are 4 RecordDML methods with overloads (enum, string or integer, sObject, string or integer). So any type of Update you want (insert from insert, update from update, insert from update, update from insert, etc) can be supported.
But the MOST important thing is what each of those parameters represents; especially the last one.
The first parameter is an ENUM (InsertDML, UpdateDML, DeleteDML) to indicate what kind of DML this will be. It will maintain the applicable DML MAP as well as a MAP to associate any errors from the DML to the requestor. The 2nd parameter is the new record ID or index number. If it is an Insert, then you'll want to use a loop counter for this parameter. Alternatively, you could use the helper.sObjInsMAP.keySet().size(). The 3rd parameter is the actual record you want to perform the DML on cast to an sObject.
The FOURTH parameter is the most important as this is how you tie this DML to the requesting record. Which record is responsible for this DML? Sometimes this can be a challenge depending on the DML you need to do and how you get to that point. You may have to create a MAP or two to keep track of things, but suffice to say, once you have it, this is where you put that reference (ID or trigger LIST index in the case of Inserts). This is vital - otherwise, you may receive "invalid use of NULL" on your trigger when it tries to report any errors.
BUT, if you follow the part about creating the sObject to Insert/Update/Delete, and then maintaining the referencing ID to pass the Helper, this becomes surprisingly easy. No more worrying about performing your DML's, capturing errors, reporting them. And what if the record has been updated before - now you'll overwrite your update with another. And where to put that logic and are you using too many DML's, etc. All of this became handled for you.
OK, great! But how is all this magic happening? OK, NOW we'll take about Trigger_Dispatcher and Trigger_Helper; your inherited class, and the class IT instantiates for you.
Trigger_Dispatcher
FINALLY, what is this magic inherited class anyway? It's not magic, but it certainly is helpful. It's a little large (over 150 lines), so we won't talk about everything it does. Just hit on the highlights and you can discover some things for yourself.
@Description('Main virtual class for Dispatcher classes to EXTEND from')
public virtual Inherited Sharing class Trigger_Dispatcher {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 08/22/2018 To provide a host class for all trigger Dispatcher implementations
*/
public MAP<string, string> errorMAP {get{if (errorMAP == null) errorMAP = new MAP<string, string>(); return errorMAP;} set;}
public Trigger_Helper helper = new Trigger_Helper();
public string strTestFlag;
//UNDELETE
public boolean bIsBefore, bIsAfter, bIsInsert, bIsUpdate, bIsDelete, bIsUnDelete;
@Description('Main virtual class Handler for all error controls and DML')
public boolean Trigger_Handler(MAP<ID, sObject> triggerOldMAP, MAP<ID, sObject> triggerNewMAP, LIST<sObject> triggerNewLIST, System.TriggerOperation operation){
/*
Created By: Robert Nunemaker
Created On: 08/22/2018
Purpose: To provide a host Trigger_Handler for all trigger Dispatcher implementations
--------------------------------------------------------------------------
Modified By:
Modified On:
Modification:
*/
boolean bAllowPreventRecursion = false;
LogUtility.debug( 'triggerNewLIST=' + triggerNewLIST );
if (strTestFlag == null || !strTestFlag.equalsIgnoreCase('TestClass')) {
helper.sObjInsMAP = new MAP<string, sObject>(); // Must initialize in case of recursive call
helper.sObjUpdMAP = new MAP<string, sObject>(); // Must initialize in case of recursive call
helper.sObjDelMAP = new MAP<string, sObject>(); // Must initialize in case of recursive call
helper.sObjInsErrMAP = new MAP<integer, string>(); // Must initialize in case of recursive call
helper.sObjUpdErrMAP = new MAP<integer, string>(); // Must initialize in case of recursive call
helper.sObjDelErrMAP = new MAP<integer, string>(); // Must initialize in case of recursive call
} // END if (strTestFlag <> null && !strTestFlag.equalsIgnoreCase('TestClass'))
system.debug('helper.sObjUpdErrMAP' + helper.sObjUpdErrMAP);
integer nIndex = 0;
bIsBefore = false;
bIsAfter = false;
bIsInsert = false;
bIsUpdate = false;
bIsDelete = false;
bIsUnDelete = false;//UNDELETE
switch on operation {
when BEFORE_INSERT {
bIsBefore = true;
bIsInsert = true;
} // END BEFORE_INSERT
when BEFORE_UPDATE {
bIsBefore = true;
bIsUpdate = true;
} // END BEFORE_UPDATE
when BEFORE_DELETE {
bIsBefore = true;
bIsDelete = true;
} // END BEFORE_DELETE
when AFTER_INSERT {
bIsAfter = true;
bIsInsert = true;
} // END AFTER_INSERT
when AFTER_UPDATE {
bIsAfter = true;
bIsUpdate = true;
} // END AFTER_UPDATE
when AFTER_DELETE {
bIsAfter = true;
bIsDelete = true;
} // END AFTER_DELETE
when AFTER_UNDELETE {
bIsAfter = true;
bIsUnDelete = true;
} // END AFTER_DELETE
} // END switch on operation
system.debug('strTestFlag: ' + strTestFlag + ', IsBefore: ' + bIsBefore + ', IsAfter: ' + bIsAfter + ', IsInsert: ' + bIsInsert + ', IsUpdate: ' + bIsUpdate + ', IsDelete: ' + bIsDelete + ', IsUndelete: '+ bIsUnDelete);
system.debug('helper: ' + helper);
// ********** ACTION LOGIC **********
this.Action(triggerOldMAP, triggerNewMAP, triggerNewLIST, operation);
system.debug('helper.sObjInsMAP.values(): ' + helper.sObjInsMAP.values());
system.debug('helper.sObjUpdMAP.values(): ' + helper.sObjUpdMAP.values());
system.debug('helper.sObjDelMAP.values(): ' + helper.sObjDelMAP.values());
// A lot of inline IF's below so that code coverage is easier.
LIST<database.DeleteResult> drLIST = new LIST<database.DeleteResult>();
LIST<database.SaveResult> srLIST = new LIST<database.SaveResult>();
boolean bDMLErrors = false;
system.debug('helper.sObjUpdErrMAP' + helper.sObjUpdErrMAP);
for (integer nEnum = 0; nEnum < Trigger_Helper.DMLType.values().size(); nEnum++) {
MAP<string, sObject> dmlMAP = (nEnum == Trigger_Helper.DMLType.DeleteDML.ordinal() ? helper.sObjDelMAP : (nEnum == Trigger_Helper.DMLType.UpdateDML.ordinal() ? helper.sObjUpdMAP : (nEnum == Trigger_Helper.DMLType.InsertDML.ordinal() ? helper.sObjInsMAP : null)));
MAP<integer, string> dmlErrMAP = (nEnum == Trigger_Helper.DMLType.DeleteDML.ordinal() ? helper.sObjDelErrMAP : (nEnum == Trigger_Helper.DMLType.UpdateDML.ordinal() ? helper.sObjUpdErrMAP : (nEnum == Trigger_Helper.DMLType.InsertDML.ordinal() ? helper.sObjInsErrMAP : null)));
//System.debug('dmlErrMAP: ' + dmlErrMAP);
if (dmlMAP.values().size() > 0) {
nIndex = 0;
if (nEnum == Trigger_Helper.DMLType.DeleteDML.ordinal()) drLIST = database.Delete(dmlMAP.values(), false);
if (nEnum == Trigger_Helper.DMLType.UpdateDML.ordinal()) srLIST = database.Update(dmlMAP.values(), false);
if (nEnum == Trigger_Helper.DMLType.InsertDML.ordinal()) srLIST = database.Insert(dmlMAP.values(), false);
LIST<Object> objResults = (nEnum == Trigger_Helper.DMLType.DeleteDML.ordinal() ? (LIST<Object>) drLIST : (LIST<Object>) srLIST);
//system.debug('Delete Result LIST: ' + drLIST);
//system.debug('Save Result LIST: ' + srLIST);
for (Object objItem : objResults) {
if ((operation == System.TriggerOperation.BEFORE_DELETE || operation == System.TriggerOperation.AFTER_DELETE) && nEnum == Trigger_Helper.DMLType.DeleteDML.ordinal() && !((database.DeleteResult) objItem).isSuccess()) {
if (dmlErrMAP.get(nIndex).isNumeric()) {
triggerOldMAP.values()[Integer.valueOf(dmlErrMAP.get(nIndex))].addError(((database.DeleteResult) objItem).getErrors()[0].getMessage());
} else {
triggerOldMAP.get(dmlErrMAP.get(nIndex)).addError(((database.DeleteResult) objItem).getErrors()[0].getMessage());
}// END if (dmlErrMAP.get(nIndex).isNumeric())
bDMLErrors = true;
} // END
if ((operation == System.TriggerOperation.BEFORE_INSERT || operation == System.TriggerOperation.AFTER_INSERT || operation == System.TriggerOperation.AFTER_UNDELETE || operation == System.TriggerOperation.BEFORE_UPDATE || operation == System.TriggerOperation.AFTER_UPDATE) && nEnum <> Trigger_Helper.DMLType.DeleteDML.ordinal() && !((database.SaveResult) objItem).isSuccess()) {
if (dmlErrMAP.get(nIndex).isNumeric()) {
triggerNewLIST[integer.valueOf(dmlErrMAP.get(nIndex))].addError(((database.SaveResult) objItem).getErrors()[0].getMessage());
} else {
triggerNewMAP.get(dmlErrMAP.get(nIndex)).addError(((database.SaveResult) objItem).getErrors()[0].getMessage());
} // END if (dmlErrMAP.get(nIndex).isNumeric())
bDMLErrors = true;
} // END
nIndex += 1;
} // END for (Object objItem : objResults)
} // END if (helper.dmlMAP.values().size() > 0)
} // END for (integer nEnum = 0; nEnum < Trigger_Helper.DMLType.values().size()); nEnum++)
bAllowPreventRecursion = !bDMLErrors;
system.debug('bDMLErrors: ' + bDMLErrors + ', bAllowPreventRecursion: ' + bAllowPreventRecursion);
system.debug('Applying errorMAP (if any): ' + errorMAP);
for (string strError : errorMAP.keySet()) {
if (operation == System.TriggerOperation.BEFORE_DELETE || operation == System.TriggerOperation.AFTER_DELETE) triggerOldMAP.get(strError).addError(errorMAP.get(strError));
if (operation == System.TriggerOperation.BEFORE_INSERT || operation == System.TriggerOperation.AFTER_INSERT || operation == System.TriggerOperation.BEFORE_UPDATE || operation == System.TriggerOperation.AFTER_UPDATE || operation == System.TriggerOperation.AFTER_UNDELETE) {
//triggerNewMAP.values()[nIndex].addError(errorMAP.get(strError));
if (strError.isNumeric()) {
triggerNewLIST[integer.valueOf(strError)].addError(errorMAP.get(strError));
} else {
triggerNewMAP.get(strError).addError(errorMAP.get(strError));
} // END if (dmlErrMAP.get(nIndex).isNumeric())
} // END if (operation == System.TriggerOperation.BEFORE_INSERT || operation == System.TriggerOperation.AFTER_INSERT ...
//system.debug('errorMAP handled: ' + errorMAP);
} // END for (string strError : errorMAP.keySet())
if (errorMAP.keySet().size() > 0) bAllowPreventRecursion = false;
return bAllowPreventRecursion;
} // END Trigger_Handler
@Description('Main virtual class signature for the actual Actionable methods to be called. Using this instead of an Interface to reduce complexity.')
public virtual void Action(
MAP<ID, sObject> triggerOldMAP,
MAP<ID, sObject> triggerNewMAP,
LIST<sObject> triggerNewLIST,
System.TriggerOperation operation){
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 08/22/2018 To provide a host Action method for all trigger Dispatcher implementations
*/
} // END Action
} // END Trigger_Dispatcher
Oh, where to start?! OK, the beginning is always a good place. Notice it is declared with "Virtual". That is what allows us to "Extend" this class. For those unsure of what that means, it's kind of like a template. You get to use all of the stuff they provide, but you also can add stuff of your own.
We also are instantiating an instance of Trigger_Helper specific to this transaction. That is where "helper" comes from in all of the snippets above. In addition, we create a bunch of booleans (bIsInsert, bIsBefore, etc) that we can refer to and use in our Dispatcher implementation.
OK, NOW the Trigger_Hanlder. This is what we call from our Trigger, remember? I'll let you discover some of the nuances on your own, but for the most part, this manages the DML MAPs, as well as the associated error MAPs to go along with them. In addition, it will call the virtual Action method which is what you implement in your Dispatcher, and then perform any DML and/or error handling for you. Imagine if you had to implement this for every trigger. Now you can see where the savings start to come.
Another important thing it does it keep track of errors for recursion. If there were any errors, it will pass back to ALLOW recursion because of the way Salesforce handles processing (if there are errors, Salesforce will retry the SUCCESSES up to 3X). Without this, your functionality would not actually "stick". No errors - it just wouldn't commit.
Finally, we have the virtual Action method that you MUST implement in your Dispatcher. This is what is called in Trigger_Dispatcher, and by implementing it, the extended class will call your implementation.
There is so much to discover in this class if you desire. Suffice to say you have your DML taken care of, any error handling taken care of, and a bunch of booleans to utilize as needed (i.e. bIsBefore, bIsAfter, bIsInsert, bIsUpdate, bIsDelete, bIsUnDelete).
But, we have to tie it all together with the Trigger_Helper. Because that class is where all of the DML's get handled along with your error association.
Let's see this final component.
Trigger_Helper
The final component of all of this is the Trigger_Helper which is called by the Trigger_Dispatcher (specific to your transaction), and is where your DML handling is done as well as associated with any errors.
Here is the code:
@Description('Helper Class to handle static recursion and DML storage')
public Inherited Sharing class Trigger_Helper {
/*
Created/Modified By Created/Modified On Modification
Robert Nunemaker 08/22/2018 To provide a host class for recursion flags and DML objects that can be used in a static form.
*/
private class customException extends Exception {}
public MAP<string, sObject> sObjInsMAP = new MAP<string, sObject>();
public MAP<string, sObject> sObjUpdMAP = new MAP<string, sObject>();
public MAP<string, sObject> sObjDelMAP = new MAP<string, sObject>();
public MAP<integer, string> sObjInsErrMAP = new MAP<integer, string>();
public MAP<integer, string> sObjUpdErrMAP = new MAP<integer, string>();
public MAP<integer, string> sObjDelErrMAP = new MAP<integer, string>();
public enum DMLType {DeleteDML, UpdateDML, InsertDML} // , UpsertDML
public string strDispatcherClass = '';
private string RecordDML(DMLType dmlType, string strKey, sObject value, string strReqID, boolean bCoreHandler) {
if (strReqID == null || strReqID == '') {
//throw new customException('Requesting record ID or number MUST be provided');
} // END if (strReqID == null || strReqID == '')
if (dmlType == Trigger_Helper.DMLType.UpdateDML && strKey != null && (strKey.length() == 15 || strKey.length() == 18) && sObjDelMAP.containsKey(strKey)) {
return null;
} // end if (dmlType == Trigger_Helper.DMLType.UpdateDML && strKey != null && (strKey.length() == 15 || strKey.length() == 18) && sObjDelMAP.containsKey(strKey)) {
MAP<string, sObject> dmlMAP = (dmlType == Trigger_Helper.DMLType.InsertDML ? sObjInsMAP : (dmlType == Trigger_Helper.DMLType.UpdateDML ? sObjUpdMAP : (dmlType == Trigger_Helper.DMLType.DeleteDML ? sObjDelMAP : null))); // null Default is for Upsert which isn't supported yet.
MAP<integer, string> dmlErrMAP = (dmlType == Trigger_Helper.DMLType.InsertDML ? sObjInsErrMAP : (dmlType == Trigger_Helper.DMLType.UpdateDML ? sObjUpdErrMAP : (dmlType == Trigger_Helper.DMLType.DeleteDML ? sObjDelErrMAP : null))); // null Default is for Upsert which isn't supported yet.
dmlMAP.put(strKey, value);
dmlErrMAP.put(dmlErrMAP.size(), strReqID);
system.debug('dmlMap' + dmlMAP + '00000' + dmlErrMAP);
return null;
} // END RecordDML
public string RecordDML(DMLType dmlType, string strKey, sObject value, string strReqID) {
return RecordDML(dmlType, strKey, value, strReqID, true);
} // END RecordDML
public string RecordDML(DMLType dmlType, integer nKey, sObject value, string strReqID) {
return RecordDML(dmlType, string.valueOf(nKey), value, strReqID, true);
} // END RecordDML
public string RecordDML(DMLType dmlType, string strKey, sObject value, integer nReqID) {
return RecordDML(dmlType, strKey, value, string.valueOf(nReqID), true);
} // END RecordDML
public string RecordDML(DMLType dmlType, integer nKey, sObject value, integer nReqID) {
return RecordDML(dmlType, string.valueOf(nKey), value, string.valueOf(nReqID), true);
} // END RecordDML
} // END Trigger_Helper
This is more straight-forward than the others. It simply contains the 4 overloads of the RecordDML method and the super-RecordDML method that each one will actually use to handle those requests. Most importantly, it marries up the errorMAP's with the applicable DML's.
Putting It All Together
So, how do you implement this yourself?
- First, Copy and Save the Trigger_Dispatcher and Trigger_Helper to your system. I didn't include the Test Class because creating that yourself will help you understand the implementation. But if you get stuck, ping me and I'll send you a start one.
- Once you've saved those, create your Helper class. Just copy/paste what I have above and change the name.
- Now create an empty Services class with your first method signature (all methods called from Dispatcher should have the same signature and return back the same errorMAP).
- Now create a Dispatcher class. Just copy/paste what I have above and change the name as well as the service class property. Remove the body of the Action method for now until you have something to implement.
- Finally, create your Trigger. Again, just copy/paste what I have above, and change the name of the Dispatcher class and Helper class.
DONE! Now just implement your services method, create a call (and consume the errorMAP) with a single line in your Dispatcher and you have your functionality.
If you are converting an existing trigger, just remember:
- ALL logic should now go to a Services class.
- ALL methods should NOT be Static.
- The trigger is one line, and should in most cases, never be changed again.
- Dispatcher is as easy as comparing for the event you want (i.e. bIsBefore && bIsInsert), and then comparing to make sure you should fire it and done have recursion (i.e. !helper.bPreventBI).
Conclusion
So we've seen how to implement a trigger dispatcher and helper that will help us keep our triggers up to best practices, and make implementation repeatable not only among team members but also any contractors or vendors.
Again, it may look overly complicated, but once you implement it the first time, you'll see that adding a new method takes SO MUCH LESS time and is still just as reliable.
Once we implemented this, our triggers (and changes) are not only faster to create, but error handling is almost an after-thought because of the handling for us.
I welcome any comments and hope this helps you on your road to a more secure and robust trigger process.
Awesome article Bob...