NPSP Table Driven Trigger Management (TDTM)

Beginning with the upcoming NPSP Release 3.0 (aka Cumulus), all trigger functionality is controlled through TDTM. As defined in the hub article. Be sure to review this article and watch the video on youtube:

Table Driven Trigger Management is a new method for managing the behavior of your Nonprofit Starter Pack code. Instead of each piece of functionality being driven by its own trigger, there is only a single trigger on each object. That trigger then calls a database table that provide a list of code to run in conjunction with the trigger action.

Prior to TDTM, the order was largely determined based on various classes calling each other, creating dependencies between those classes, and generally difficult-to-read code. Now those operations happen independently, and any associated database action (DML) are held until all actions are complete, if possible. This has the effect of speeding up your Nonprofit Starter Pack behavior. In addition, if you wanted to add any custom functionality that depended on these actions occurring, for example, creating a child object on the Opportunity based on the Contact Roles, you had no way of ensuring your code would run in the proper order. With TDTM, you can now implement the common interface, and add your custom code to the TDTM table in the settings panel, and define the order you want your code to be called in.

While migrating a client to the new version of the NPSP, I set out to see what TDTM could do. Before delving into the code, here are some pros and cons to using TDTM for your triggers:

Pros:

  • Obviously, your custom code will work and play quite well with NPSP Triggers
  • Provides superior control over the execution order of your code, both relative to NPSP Triggers and relative to your own customizations. Anyone who has followed the single-trigger-per-object pattern in the past will be familiar with this already.
  • Relatively straight forward to implement

Cons:

  • TDTM is only available for a small list of objects: Account, Contact, CampaignMember, Opportunity, and the key NPSP objects (Address, Relationship, Affiliation, Household, RecurringDonation). As of this blog post, it is not possible to extend the use of TDTM to any other objects. There is an open story on Github for this here.
  • Apex Unit Tests that do not use SeeAllData=true will not run your custom TDTM Trigger code unless you recreate the TDTM records prior to creating your test records. The packaged TDTM triggers will run even with SeeAllData=false. In my sample code below, recreating the custom trigger handler references is handled through the custom InitTDTM() method. There is an open story on Github for this here.
  • Any custom TDTM Trigger records created in Production will not be copied to a new or refreshed Sandbox and must be recreated in that org. As with above, any NPSP standard Trigger Handlers are automatically recreated in the Sandbox either by visiting the Settings page, or the first time you create a record for one of the objects Cumulus interacts with.
  • Custom apex code errors are masked and logged to the NPSP Error Log. This prevents your users from getting long apex error messages due to code problems, however it also complicates the debugging process. In particular, testing of code logic through Unit Tests (though this is best practice) are affected here because the code just doesn’t run with no visible explanation. System.Assert statements are key to identifying where a problem may exist, however debugging the exact root cause may be a challenge.

Implementing Custom TDTM Triggers

There various possible design patterns to follow with TDTM, such as:

  1. A single TDTM Handler Class that controls flow for all objects, or at least the objects that the TDM is capable of handling.
  2. One TDTM Handler Class per object.

For the purposes of this writing, we’ll stick with option 1. It’s easy enough to create split this out into one class per object. For simplicity, I’m using a second class to hold all the business logic (methods) called via the TDTM Trigger handler though that class is not included here as it’s not necessary to illustrate the overall usage of TDTM. Depending on the complexity of your implementation, those methods could be separated out into a single class per object.

The sample class  below is enough to get you started with TDTM. The class calls a second class (TDTM_Processes) where the actual business logic is located. Your actual implementation can vary as appropriate based on org standards, business processes, etc.. For example, this class could call multiple other classes, such as TDTM_Account_Trigger and TDTM_Contact_Trigger.

Have fun!

global without sharing class TDTM_Handler extends npsp.TDTM_Runnable {

	private static final String THIS_CLASS_NAME = 'TDTM_Handler';
	private static final Integer TRIGGER_LOAD_ORDER = 2;

	// Custom TDTM Trigger details used to recreate TDTM records for Unit Tests or in a Sandbox
	private static final map TDTMconfig = new map {
			'Account' => 'BeforeInsert;BeforeUpdate;AfterInsert;AfterUpdate',
			'Contact' => 'BeforeInsert;BeforeUpdate;AfterInsert;AfterUpdate;AfterDelete',
			'Opportunity' => 'BeforeInsert;BeforeUpdate;AfterInsert;AfterUpdate;AfterDelete'
		};

	/*
	* Main method called by the TDTM trigger
	*/
	global override DmlWrapper run(List listNew, List listOld,
		npsp.TDTM_Runnable.Action triggerAction, Schema.DescribeSObjectResult objResult) {

		triggerInfo trgr = new triggerInfo(triggerAction, objResult);

		if (trgr.sObjectName == 'Contact') {
			if (trgr.isBefore && trgr.isInsert) {
				TDTM_Processes.Contact_DoSomethingOnBeforeInsert(trgr, listNew);
			}
		} else if (trgr.sObjectName == 'Account') {
			if (trgr.isAfter && trgr.isUpdate) {
				TDTM_Processes.Account_DoSomethingOnAfterUpdate(trgr, listNew, makeMapFromSObjectList(listOld));
			}
		} else if (trgr.sObjectName == 'Opportunity') {
			if (trgr.isUpdate) {
				TDTM_Processes.Opportunity_DoSomethingOnUpdate(trgr, listNew, makeMapFromSObjectList(listOld));
			}
		}

		return null;
	}

	/*
	* Initialize the TDTM configuration.
	* Helpful in Unit Tests to ensure that the "triggers" are in place for tests
	* Also helps to document & rebuild the trigger configuration in case the TDTM
	* records are deleted or for new/refreshed Config/Dev Sandboxes.
	*/
	global static integer initTDTM() {

		// Find any existing TDTM config records related to THIS class
		// and match against the ones to be created
		for (npsp__Trigger_Handler__c th : [SELECT ID, npsp__Class__c, npsp__Object__c,
				npsp__Trigger_Action__c FROM npsp__Trigger_Handler__c
				WHERE npsp__Active__c = True AND npsp__Class__c = :THIS_CLASS_NAME]) {
			if (TDTMconfig.containsKey(th.npsp__Object__c)) {
				TDTMconfig.remove(th.npsp__Object__c);
			}
		}

		// Create TDTM records that did not exist before
		list tdtm = new list();
		for (String obj : TDTMconfig.keySet()) {
			tdtm.add(new npsp__Trigger_Handler__c(
				npsp__Class__c = THIS_CLASS_NAME,
				npsp__Object__c = obj,
				npsp__Active__c = true,
				npsp__User_Managed__c = true,
				npsp__Load_Order__c	= TRIGGER_LOAD_ORDER,
				npsp__Trigger_Action__c = TDTMconfig.get(obj)
			));
		}
		if (tdtm.size() > 0) {
			database.insert(tdtm, false);
		}
		return tdtm.size();
	}

	/*
	* Utility Method:
	* Convert a list of sObjects into a map by Id (returns null if list is null)
	*/
	private static Map<Id, SObject> makeMapFromSObjectList(List<SObject> lstSobject) {
		if (lstSobject == null) return null;
		Map<Id, SObject> theMap = new Map<Id, SObject>();
		for (sObject s : lstSobject) {
			theMap.put((Id)s.get('Id'), s);
		}
		return theMap;
	}

	/* 
	* Holds the trigger context variables as an instance to pass to the processing methods
	* if required. This ensures that the specific vars are tied to the context in which they're
	* being run. Using static class vars in this class will not work because they're overwritten
	* in the case of nested trigger calls in a single context.
	*/
	public class triggerInfo {
		public Boolean isInsert = false; // These mimic the properties in 
		public Boolean isUpdate = false; // standard Salesforce Triggers
		public Boolean isBefore = false;
		public Boolean isAfter  = false;
		public Boolean isDelete = false;
		public Boolean isUndelete = false;
		public String sObjectName;

		public triggerInfo(npsp.TDTM_Runnable.Action triggerAction, Schema.DescribeSObjectResult objResult) {
			this.isInsert = (triggerAction == npsp.TDTM_Runnable.Action.BeforeInsert
				|| triggerAction == npsp.TDTM_Runnable.Action.AfterInsert);
			this.isUpdate = (triggerAction == npsp.TDTM_Runnable.Action.BeforeUpdate
				|| triggerAction == npsp.TDTM_Runnable.Action.AfterUpdate);
			this.isBefore = (triggerAction == npsp.TDTM_Runnable.Action.BeforeInsert
				|| triggerAction == npsp.TDTM_Runnable.Action.BeforeUpdate
				|| triggerAction == npsp.TDTM_Runnable.Action.BeforeDelete);
			this.isAfter = !isBefore;
			this.isDelete = (triggerAction == npsp.TDTM_Runnable.Action.BeforeDelete
				|| triggerAction == npsp.TDTM_Runnable.Action.AfterDelete);
			this.isUndelete = (triggerAction == npsp.TDTM_Runnable.Action.AfterUndelete);
			this.sObjectName = objResult.getName();
		}
	}