Trigger Actions Framework (TAF) Guide
Overview
The Trigger Actions Framework provides a metadata-driven approach to trigger management, enabling:
- One trigger per object
- Ordered execution of actions
- Bypass mechanisms (global, transaction, permission-based)
- Support for both Apex and Flow actions
Installation
Install from: https://github.com/mitchspano/trigger-actions-framework
Basic Setup
1. Create the Trigger
One trigger per object, delegating all logic to the framework:
trigger AccountTrigger on Account (
before insert, after insert,
before update, after update,
before delete, after delete,
after undelete
) {
new MetadataTriggerHandler().run();
}
2. Enable the Object
Create an sObject_Trigger_Setting__mdt record:
- Label: Account Trigger Setting
- Object API Name: Account
- Bypass Execution: unchecked
3. Create Action Classes
Each action class handles one specific behavior:
public class TA_Account_SetDefaults implements TriggerAction.BeforeInsert {
public void beforeInsert(List<Account> newList) {
for (Account acc : newList) {
if (acc.Industry == null) {
acc.Industry = 'Other';
}
}
}
}
4. Register the Action
Create a Trigger_Action__mdt record:
- Object: Account
- Apex Class Name: TA_Account_SetDefaults
- Order: 1
- Before Insert: checked
Action Interfaces
Before Triggers
// Before Insert
public class MyAction implements TriggerAction.BeforeInsert {
public void beforeInsert(List<SObject> newList) { }
}
// Before Update
public class MyAction implements TriggerAction.BeforeUpdate {
public void beforeUpdate(List<SObject> newList, List<SObject> oldList) { }
}
// Before Delete
public class MyAction implements TriggerAction.BeforeDelete {
public void beforeDelete(List<SObject> oldList) { }
}
After Triggers
// After Insert
public class MyAction implements TriggerAction.AfterInsert {
public void afterInsert(List<SObject> newList) { }
}
// After Update
public class MyAction implements TriggerAction.AfterUpdate {
public void afterUpdate(List<SObject> newList, List<SObject> oldList) { }
}
// After Delete
public class MyAction implements TriggerAction.AfterDelete {
public void afterDelete(List<SObject> oldList) { }
}
// After Undelete
public class MyAction implements TriggerAction.AfterUndelete {
public void afterUndelete(List<SObject> newList) { }
}
Common Patterns
Setting Default Values (Before Insert)
public class TA_Account_SetDefaults implements TriggerAction.BeforeInsert {
public void beforeInsert(List<Account> newList) {
for (Account acc : newList) {
acc.Industry = acc.Industry ?? 'Other';
acc.NumberOfEmployees = acc.NumberOfEmployees ?? 0;
}
}
}
Validation (Before Insert/Update)
public class TA_Account_ValidateData implements TriggerAction.BeforeInsert, TriggerAction.BeforeUpdate {
public void beforeInsert(List<Account> newList) {
validate(newList);
}
public void beforeUpdate(List<Account> newList, List<Account> oldList) {
validate(newList);
}
private void validate(List<Account> accounts) {
for (Account acc : accounts) {
if (acc.AnnualRevenue != null && acc.AnnualRevenue < 0) {
acc.AnnualRevenue.addError('Annual Revenue cannot be negative');
}
}
}
}
Related Record Updates (After Insert/Update)
public class TA_Account_UpdateContacts implements TriggerAction.AfterUpdate {
public void afterUpdate(List<Account> newList, List<Account> oldList) {
Map<Id, Account> oldMap = new Map<Id, Account>(oldList);
Set<Id> changedAccountIds = new Set<Id>();
for (Account acc : newList) {
Account oldAcc = oldMap.get(acc.Id);
if (acc.BillingCity != oldAcc.BillingCity) {
changedAccountIds.add(acc.Id);
}
}
if (!changedAccountIds.isEmpty()) {
updateContactAddresses(changedAccountIds);
}
}
private void updateContactAddresses(Set<Id> accountIds) {
List<Contact> contacts = [
SELECT Id, AccountId, MailingCity
FROM Contact
WHERE AccountId IN :accountIds
WITH USER_MODE
];
Map<Id, Account> accounts = new Map<Id, Account>([
SELECT Id, BillingCity
FROM Account
WHERE Id IN :accountIds
WITH USER_MODE
]);
for (Contact con : contacts) {
con.MailingCity = accounts.get(con.AccountId).BillingCity;
}
update contacts;
}
}
Async Processing (After Insert/Update)
public class TA_Account_ProcessAsync implements TriggerAction.AfterInsert {
public void afterInsert(List<Account> newList) {
Set<Id> accountIds = new Map<Id, Account>(newList).keySet();
System.enqueueJob(new AccountProcessingQueueable(accountIds));
}
}
Bypass Mechanisms
Global Bypass (Metadata)
In sObject_Trigger_Setting__mdt:
- Set
Bypass_Execution__c = trueto disable all triggers for object
Transaction Bypass (Apex)
// Bypass specific object
TriggerBase.bypass(Account.SObjectType);
// Bypass specific action
MetadataTriggerHandler.bypass('TA_Account_SetDefaults');
// Clear bypasses
TriggerBase.clearAllBypasses();
MetadataTriggerHandler.clearAllBypasses();
Permission-Based Bypass
In Trigger_Action__mdt:
Bypass_Permission__c: Users with this permission skip the actionRequired_Permission__c: Only users with this permission run the action
Recursion Prevention
Using TriggerBase
public class TA_Account_PreventRecursion implements TriggerAction.AfterUpdate {
public void afterUpdate(List<Account> newList, List<Account> oldList) {
List<Account> toProcess = new List<Account>();
for (Account acc : newList) {
// Check if already processed in this transaction
if (!TriggerBase.idToNumberOfTimesSeenAfterUpdate.get(acc.Id).equals(1)) {
continue;
}
toProcess.add(acc);
}
if (!toProcess.isEmpty()) {
processAccounts(toProcess);
}
}
}
Flow Actions
Setup
Create an Autolaunched Flow
Add variables:
record(Input, Record type)recordPrior(Input, Record type, for Update triggers)
Create
Trigger_Action__mdt:- Apex Class Name:
TriggerActionFlow - Flow Name:
Your_Flow_API_Name
- Apex Class Name:
Entry Criteria
Define formula criteria to control when the flow executes:
{!record.Status__c} = 'Submitted' && {!recordPrior.Status__c} != 'Submitted'
Testing
Test Action Classes
@isTest
private class TA_Account_SetDefaultsTest {
@isTest
static void testBeforeInsert() {
Account acc = new Account(Name = 'Test');
Test.startTest();
insert acc;
Test.stopTest();
Account result = [SELECT Industry FROM Account WHERE Id = :acc.Id];
Assert.areEqual('Other', result.Industry, 'Default industry should be set');
}
@isTest
static void testBulkInsert() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 251; i++) {
accounts.add(new Account(Name = 'Test ' + i));
}
Test.startTest();
insert accounts;
Test.stopTest();
List<Account> results = [SELECT Industry FROM Account WHERE Id IN :accounts];
for (Account acc : results) {
Assert.areEqual('Other', acc.Industry);
}
}
}
Test with Bypass
@isTest
static void testWithBypass() {
// Bypass the action
MetadataTriggerHandler.bypass('TA_Account_SetDefaults');
Account acc = new Account(Name = 'Test');
insert acc;
Account result = [SELECT Industry FROM Account WHERE Id = :acc.Id];
Assert.isNull(result.Industry, 'Industry should not be set when bypassed');
// Clear bypass for other tests
MetadataTriggerHandler.clearBypass('TA_Account_SetDefaults');
}
Naming Convention
TA_[ObjectName]_[ActionDescription]
Examples:
- TA_Account_SetDefaults
- TA_Account_ValidateData
- TA_Contact_UpdateAccountRollup
- TA_Opportunity_SendNotification
Execution Order
Actions execute in the order defined by the Order__c field in Trigger_Action__mdt.
Recommended ordering:
- Validation (10-20)
- Default values (30-40)
- Field calculations (50-60)
- Related record queries (70-80)
- Related record updates (90-100)
- Async/external calls (110+)