11 KiB
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
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
Custom Permissions as Preferred Bypass (Recommended)
Custom Permissions provide a scalable, declarative bypass mechanism — preferred over hardcoded profile/role names.
public class TA_Account_SetDefaults implements TriggerAction.BeforeInsert {
public void beforeInsert(List<Account> newList) {
// Check Custom Permission — preferred over hardcoded profile names
if (FeatureManagement.checkPermission('Bypass_Triggers')) {
return;
}
for (Account acc : newList) {
acc.Industry = acc.Industry ?? 'Other';
}
}
}
Why Custom Permissions > hardcoded profile names: Custom Permissions can be assigned via Permission Sets (stackable, user-specific), don't break when profiles are renamed, and work across all automation types (Apex, Flow, Validation Rules).
Recursion Prevention
Field-Value Comparison (Recommended)
The official recommendation for recursion prevention is to compare old vs new field values, processing only records where the relevant field actually changed. This is more precise than static boolean flags.
public class TA_Account_SyncExternal implements TriggerAction.AfterUpdate {
public void afterUpdate(List<Account> newList, List<Account> oldList) {
Map<Id, Account> oldMap = new Map<Id, Account>(oldList);
List<Account> toProcess = new List<Account>();
for (Account acc : newList) {
Account oldAcc = oldMap.get(acc.Id);
// Only process if the field we care about actually changed
if (acc.Status__c != oldAcc.Status__c) {
toProcess.add(acc);
}
}
if (!toProcess.isEmpty()) {
processAccounts(toProcess);
}
}
}
Why field-value comparison > static boolean flags: Static booleans (
hasRun = true) prevent ALL re-entry, even legitimate re-entry from different field changes. Field-value comparison only skips records where the triggering field didn't change.
Using TriggerBase (TAF-Specific)
TAF provides built-in recursion tracking via idToNumberOfTimesSeenAfterUpdate. Use this when you need to limit execution count per record regardless of which fields changed:
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+)