Salesforce’s Nonprofit Success Pack (NPSP) is one of the most widely adopted solutions for nonprofits worldwide. It handles donors, programs, volunteers, campaigns, and more. In the background, NPSP uses the Table-Driven Trigger Management (TDTM) framework to decouple business logic from standard triggers.
This guide will go into detail about how TDTM works, what the code benefits of its usage are, and how you can build custom Apex handlers that plug right into NPSP, avoiding unexpected recursions.
The Potential of Table-Driven Trigger Management
To fully understand the potential of TDTM, let’s think conversely. Without it, it’s easy for triggers to multiply until you’re juggling dozens of entry points that call one another, triggering endless recursion guards, cluttered debug logs, and surprise governor-limit exceptions.
TDTM solves this by funneling every object event through a single trigger. It handles execution order, deduplicates records automatically, and processes batches cleanly. When a new requirement needs to be implemented, the developer simply drops in a new handler class and adds one line of metadata. Another important aspect is that there is no need to edit your existing triggers or to recompile thousands of code lines. This will lead to avoiding any risk of collateral breakage.
Installation and Licensing
First of all, ensure that every user who will work on the Nonprofit Success Pack has a full Salesforce CRM licence (e.g. Sales Cloud or Service Cloud), as only these licences grant access to the standard Account, Contact and Opportunity objects on which NPSP is based.
Next, install the ‘Nonprofit Success Pack’ from AppExchange, and follow the installation wizard. After the installation is complete, find Nonprofit Success Pack on Installed Packages and click on Configure. In the configuration screen, assign the Permission Set Licence ‘Nonprofit Success Pack’, which will activate the Custom Metadata Types and the TDTM framework in the background.

As a final step, it is recommended to create and assign permission sets according to the level of use required by users, based on whether they need full control over TDTM logic (and advanced configurations), or the user only needs to enter and update nonprofit data. This way, your org will have the correct licenses, the package will be fully operational, and each team member will have exactly the permissions they need.
Use Case: Major Donors Management
Business Scenario
In order to fully understand the TDTM process, a real use case is needed. Imagine a nonprofit company whose data team spends hours each week manually marking donors as “Major” once they cross a lifetime donation threshold. Every missed update is a missed opportunity to recognize generosity. Using TDTM, you can automate this process so that the moment a donor’s cumulative Closed Won donations exceed ten thousand dollars, their Contact record flips a checkbox from false to true and subsequently triggers an email to the Contact owner.
Data Model Setup
This solution will be based on custom objects with custom fields and metadata records. Major_Donor__c is the Contact checkbox field to track the Major Donor achievement. Moreover, Opportunities and Contacts are linked through the Primary Contact lookup.
Create a Custom Metadata named MajorDonor__mdt with a currency field Threshold and a picklist field named Level (with Bronze, Silver, Gold, and Platinum picklist values) and add all threshold-level combinations.
Rather than writing and maintaining multiple triggers on Opportunity, we rely on the NPSP TDTM framework. In the Salesforce Apps, search for “Trigger Handlers,” then click on “New,” and complete the information.

Apex Code Implementation
By defining this metadata record, TDTM invokes the MajorDonorHandler Apex class related to Opportunity object in after insert and after update scenarios.
Trigger will have the following structure:
trigger TDTM_Opportunity on Opportunity (before insert, before update, after insert, after update) {
npsp.TDTM_TriggerHandler.run(Trigger.new, Trigger.oldMap,Trigger.isBefore, Trigger.isAfter,Trigger.isInsert, Trigger.isUpdate,Trigger.isDelete, Trigger.isUndelete);
}
While Apex handler will be:
public with sharing class MajorDonorHandler implements npsp.TDTM_Runnable {
private static final List<MajorDonor__mdt> METADATA = [
SELECT Threshold__c, Level__c
FROM MajorDonor__mdt
ORDER BY Threshold__c ASC
];
global override npsp.TDTM_Runnable.DmlWrapper run(
List<SObject> newRecs,
Map<Id, SObject> oldMap,
npsp.TDTM_Context context
) {
if (!context.isAfter()) {
return new npsp.TDTM_Runnable.DmlWrapper();
}
Set<Id> contactIds = new Set<Id>();
for (SObject s : newRecs) {
Opportunity o = (Opportunity) s;
Opportunity oldO = oldMap != null ? (Opportunity) oldMap.get(o.Id) : null;
Boolean nowWon = 'Closed Won'.equals(o.StageName);
Boolean wasNotWon= oldO == null || !('Closed Won'.equals(oldO.StageName));
if (o.ContactId != null && nowWon && wasNotWon) {
contactIds.add(o.ContactId);
}
}
if (contactIds.isEmpty()) {
return new npsp.TDTM_Runnable.DmlWrapper();
}
Map<Id, Decimal> totals = new Map<Id, Decimal>();
for (AggregateResult ar : [
SELECT ContactId, SUM(Amount) total
FROM Opportunity
WHERE ContactId IN :contactIds AND StageName = 'Closed Won'
GROUP BY ContactId
]) {
totals.put((Id) ar.get('ContactId'), (Decimal) ar.get('total'));
}
List<Contact> updates = new List<Contact>();
List<Messaging.SingleEmailMessage> emails = new List<Messaging.SingleEmailMessage>();
for (Id cid : totals.keySet()) {
Decimal sum = totals.get(cid);
String level;
for (MajorDonor__mdt md : METADATA) {
if (sum >= md.Threshold__c) {
level = md.Level__c;
} else {
break;
}
}
Boolean isMajor = level != null;
updates.add(new Contact(Id = cid, Major_Donor__c = isMajor, Donor_Level__c = level));
if (isMajor) {
Contact c = [
SELECT Owner.Email, Owner.Name, Name
FROM Contact
WHERE Id = :cid
LIMIT 1
];
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[]{ c.Owner.Email });
mail.setSubject('Congratulations! ' + c.Name + ' has reached Major Donor level: ' + level);
mail.setPlainTextBody(
Dear' + c.Owner.Name + ',\n\n' +
c.Name + ' you have donated a total of $' + sum.setScale(2) +
' and has achieved Major Donor status (' + level + ').\n\n' +
'Thank you,\nYour Fundraising Team'
);
emails.add(mail);
}
}
try {
update updates;
if (!emails.isEmpty()) {
Messaging.sendEmail(emails);
}
} catch (Exception e) {
System.debug('MajorDonorHandler error: ' + e.getMessage());
}
return new npsp.TDTM_Runnable.DmlWrapper();
}
}
Apex handler leverages a single SOQL aggregation in order to optimize performance and scalability. Threshold values are dynamically sourced from custom metadata, avoiding hard-coded constant usage. Moreover, error handling is applied through structured try/catch blocks for both DML and email operations.
TDTM Deployment
To deploy this solution from a User Acceptance Testing (UAT) environment, start by pushing the MajorDonorHandler Apex class (and its related Test Class) to the target environment, along with the related Custom Metadata records (MajorDonor__mdt) that define the donation thresholds and levels.
Once the technical components are set up in the package and deployed, manually create the TDTM Trigger Handler record, as done previously.
Advanced Patterns and Performance
As your nonprofit scales, you need to optimize how you handle large data volumes. First, ensure every SOQL only retrieves the fields you actually use – this minimises data transfer and speeds up execution. When you have tens of thousands of records to process, wrap your logic in a batchable class using Database.getQueryLocator(). This breaks the work into manageable chunks and can be scheduled via the Schedulable interface; consider also logging batch progress to a custom object or Lightning page so you can easily see which records succeeded or failed.
In case of external HTTP calls or integrations, manage them outside of your TDTM trigger logic. Instead, publish a Platform Event in your trigger and let an asynchronous subscriber run in the background. This approach will guarantee better performance execution and respect for governor limits.
Finally, load any Custom Metadata Type records once and store the results in a static final variable to ensure that each transaction reuses the cached data rather than querying it again and again.
Developer Tips
Here is a summary of suggestions and tips for building a bulk safe and manageable TDTM implementation:
- Treat Custom Metadata Types as your configuration hub. In this way, an admin can simply edit a metadata record to change how and when your logic runs, keeping the maintenance simple.
- Use Maps instead of Sets for ID collections whenever you need to fetch SObjects. A
Map<Id, SObject>gives direct access to the records without extra SOQL. - Keep your handlers simple and use asynchronous processes in case of third-party callouts. This keeps your TDTM execution quick and avoids hitting governor limits.
- Version your metadata records. This is important when there is a need to roll back, simply reactivating the previous version.
- Leave gaps in your
Order__cvalues, assigning 10, 20, 30 instead of 1, 2, 3. This approach is very helpful in order to avoid changing the whole trigger method’s execution order.
Final Thoughts
The TDTM framework represents a modern, scalable approach to managing automation in Salesforce. Its composition is based on reusable handlers, which led to the elimination of the need for redundant triggers; moreover, it reduces technical debt and sets up trigger order execution.
Leveraging TDTM also aligns with Salesforce’s recommended best practices, enabling organizations to evolve their automation strategy as business needs grow, making it essential for long-term success.