APEX Triggers using pattern EDA3

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.


To view or add a comment, sign in

More articles by Robert Nunemaker

  • Create a YAML file for an APEX REST Class

    Introduction With so many web services written with APEX being done in REST and fewer in SOAP, we need to be able to…

    2 Comments
  • Get All Permutations (Combinations) Using APEX

    Huh? Permutations? Yeah, every industry likes to have their own words, right? Instead of the $5 word, we can just say…

  • (Re)Discover the Power of Salesforce Custom Permissions

    Custom Permissions are one of those obscure capabilities of the Salesforce platform that is not only under-appreciated,…

    5 Comments
  • The Power of "Why"!

    (For Non-Coders and Coders alike!) Introduction Often times we need to design solutions with incomplete information. It…

    1 Comment
  • Create own Code Coverage Solution!

    A few years ago, Salesforce removed Code Coverage from the Trigger and Class GUI screens. Encouraging you to go to…

    3 Comments
  • Change Test Class Batch Size Dynamically

    Previously, we've discussed best practices for developing Test classes. And, as we all know, we should be testing in…

  • Test Class Best Practices - A Pattern

    Introduction – Why and What? As we all know, Test Classes are integral to the success of any APEX coding. Moreover, the…

  • Custom Labels, Settings, Metadata - which should I use?

    Salesforce is chock full of capabilities that allow a much more customized experience. Among these are Custom Labels…

    1 Comment
  • Upserting and Working with Custom Metadata Types

    Discussion Previously, we discussed how to use Custom Metadata Types in conjunction with generic sObjects to provide a…

    4 Comments
  • Custom Metadata & sObjects for full Generic Reads and Updates

    Discussion Ever wish you could create a service that would be able to read and/or write to a given object, but provide…

    1 Comment

Others also viewed

Explore content categories