Triggers - Guide
Framework: KernDX Package Type: Managed Package
Target Audience:
- Developers - Implementing trigger logic with modular, metadata-driven actions
- Architects - Designing scalable trigger frameworks with proper separation of concerns
- Business Analysts - Understanding trigger behaviour and configuration options
What problem does this solve?
Salesforce gives you triggers but no structure for organising what they do. Most orgs end up with one large trigger file per object that nobody wants to touch, because every change risks breaking unrelated logic buried in the same file.
This framework lets you break that logic into small, single-purpose classes called trigger actions. Configuration records, not code, then decide the run order, turn each action on or off, and add conditions.
The benefit: each piece of logic is testable on its own, safe to reorder, and can be switched off in production without a deployment.
Different roles get different value from it. Developers write the small action classes. Architects design the run order and the separation of concerns. Admins and analysts manage settings without touching Apex.
Use it whenever an object needs more than a trivial trigger and you want that logic to stay readable, testable, and changeable over time.
Mental model
Think of the framework as a playlist. Each trigger action is a song, the configuration records decide the running order, and the dispatcher presses play. You can reorder the songs, or skip one, without rewriting the music: change a configuration record, and the same small action classes run in a new order or sit out entirely.
Use this when
- An object needs more than a trivial trigger, and you want the logic split into pieces you can reorder, disable, or test on their own.
- More than one developer works on the same object's triggers and you want them to stop colliding in one file.
- You need to turn a piece of trigger logic off in production (a data load, a migration, an incident) without shipping code.
- The same validation or defaulting logic should run on more than one object.
- You want to gate or roll out trigger logic to a chosen group of users without a deployment.
Don't use this when
- A formula field or a record-triggered Flow already handles the change declaratively. Use that; it is less to maintain.
- It is a one-time data fix or migration script that runs outside normal transaction flow.
- The logic applies in a single context and will never be reused or reordered. A plain handler class is simpler.
- Raw performance is critical and you cannot spend the small framework overhead (metadata queries and reflection) on each run.
Quick Start
To add one piece of logic to an object's trigger, write a small class that extends TRG_Base and implements one IF_Trigger context interface (one interface per trigger event, such as before-insert). That class is a trigger action, and the framework runs it for you based on a configuration record.
Step-by-step walkthrough: Fast Start - Trigger Actions covers implementation, testing, and common pitfalls.
public inherited sharing class TRG_SetDefaults extends TRG_Base implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<SObject> newRecords)
{
for(SObject newRecord : newRecords)
{
if(newRecord.get('Status__c') == null)
{
newRecord.put('Status__c', 'New');
}
}
}
}For deeper coverage, continue reading the sections below.
Table of Contents
Expand
- What problem does this solve?
- Mental model
- Use this when
- Don't use this when
- Quick Start
- Quick Navigation
- Why choose this over standard Salesforce triggers?
- What are the moving parts?
- How do I configure this?
- Flow as a Trigger Action
- TriggerAction__mdt fields used by flow actions
- Variable contract on the registered flow
- LogSetting__c volume gate
- Mock harness
- When to use a flow action vs a record-triggered flow
- Failure-action strategies
- Bulk behaviour and governor limits
- Cross-namespace flow resolution
- Recommended-default pattern matrix
- Audit volume control
- Bypass and rollback
- Migrating from TAF to KernDX
- Change Data Capture Actions
- Post-Trigger Actions
- Trigger Action Interfaces
- Caching with Trigger Actions
- Caching Pattern Overview
- Implementation Pattern
- Execution Flow
- Best Practices for Caching
- Testing Cached Actions
Quick Navigation
| I am a... | I need to... | Go to... |
|---|---|---|
| Architect | Understand trigger architecture | What are the moving parts? |
| Architect | Design coordination patterns | How do I link actions together? |
| Developer | Create a trigger action | Quick Start |
| Developer | Test trigger actions | Testing |
| Developer | Use bypass mechanisms | What else can it do? |
| Developer | React to committed changes (CDC) | Change Data Capture Actions |
| Developer | Run logic once, after all trigger actions for a save finish | Post-Trigger Actions |
| Analyst | Configure trigger settings | Capability Matrix |
| Analyst | Monitor trigger performance | Performance Logging |
Why choose this over standard Salesforce triggers?
The goal is to keep trigger logic small, testable, and easy to change without redeploying code. KernDX does this with a metadata-driven Trigger Action Framework: instead of one big trigger file, you write small, focused classes (trigger actions) that each handle one piece of business logic. Configuration records, not code, then decide how those actions behave. With this framework, each action gives you:
- Control over the exact run order. Set which action runs first, second, third in a configuration field.
- Composition instead of one big method. Small actions chain together like function calls, so complex behaviour is built from simple parts.
- One query shared across actions. An early action caches data that later actions read, so you don't repeat the same SOQL.
- Per-record conditions without filtering code. An entry-criteria formula decides which records reach the action.
- An off-switch with no deployment. Disable an action at runtime during a load or an incident, then switch it back on.
- The same logic on more than one object. Point a single action class at several objects through configuration.
Trigger Framework Scope: 7
TRG_*handler classes and 6 one-line trigger files that hand control to the framework (TRG_ApiCall,TRG_ApiIssue,TRG_AsyncChainExecution,TRG_Foobar,TRG_LogEntryEvent,TRG_ScheduledJob). All of it is driven byTriggerSetting__mdtandTriggerAction__mdtcustom metadata. It supports object-level and action-level bypass, entry criteria formulas, ordered execution, and an audit trail on every programmatic bypass call.
Responsibilities: Trigger actions execute discrete units of work (validation, field defaulting, related record creation). They do not query data inline; use selectors for that. They do not contain reusable business logic; extract that to service classes.
When NOT to use this pattern:
- Simple field updates that a formula field or record-triggered Flow can handle declaratively
- One-time data fixes or migration scripts that run outside normal transaction flow
- Logic that only applies in a single context and will never be reused or reordered
In short, the framework gives you:
- Behaviour changes without a deployment. Configuration records drive trigger behaviour, so you adjust it without shipping code.
- Logic you can actually maintain. Small, focused actions with single responsibilities replace one monolithic file.
- Composable execution. Actions execute in order, like function calls, so you build complex behaviour from simple pieces.
- Shared data between actions. Early actions cache data for later actions to read.
- Independent tests. Each action can be tested on its own.
- Reuse across objects. The same action class works on multiple objects.
- Bulk-safe processing. Built-in support for processing records in bulk.
- Built-in guardrails. Automatic recursion prevention and bypass mechanisms keep runaway logic in check.
How does it work?
+---------------------------------------------------------------------------+
| TRIGGER ACTION FRAMEWORK ARCHITECTURE |
+---------------------------------------------------------------------------+
| |
| DML Operation (Insert/Update/Delete) |
| | |
| v |
| +-------------------------------------------------------------------+ |
| | Physical Trigger (e.g., TRG_Account) | |
| | trigger TRG_Account on Account (...) { new TRG_Dispatcher().run(); } |
| +-------------------------------+-----------------------------------+ |
| | |
| v |
| +-------------------------------------------------------------------+ |
| | TRG_Dispatcher | |
| | - Queries TriggerSetting__mdt for object | |
| | - Queries TriggerAction__mdt for context (before/after, ins/etc) | |
| +-------------------------------+-----------------------------------+ |
| | |
| v |
| +-------------------------------------------------------------------+ |
| | For each TriggerAction__mdt (ordered by Order__c): | |
| | | |
| | +---------------------------------------------------------------+| |
| | | 1. Check if action is Active || |
| | | 2. Evaluate Entry Criteria (formula) || |
| | | 3. Check Required Feature Flag || |
| | | 4. Instantiate TRG_* class || |
| | | 5. Execute interface method (beforeInsert, afterUpdate, etc) || |
| | +---------------------------------------------------------------+| |
| +-------------------------------------------------------------------+ |
| |
| INTERFACES: |
| +-------------+ +-------------+ +--------------+ +--------------+ |
| |IF_Trigger. | |IF_Trigger. | |IF_Trigger. | |IF_Trigger. | |
| |BeforeInsert | |AfterInsert | |BeforeUpdate | |AfterUpdate | ... |
| +-------------+ +-------------+ +--------------+ +--------------+ |
| |
+---------------------------------------------------------------------------+KernDX vs OOTB: Trigger Patterns Comparison
Salesforce Out-of-the-Box Approach
Salesforce provides triggers as a platform feature, but does not include a framework for organising trigger logic. Developers typically use one of these patterns:
- Monolithic Trigger - All logic in the trigger file
- Helper Class Pattern - Trigger calls static helper methods
- Handler Class Pattern - Trigger instantiates and calls a handler class
// Pattern 1: Monolithic (not recommended)
trigger AccountTrigger on Account (before insert, after update) {
if(Trigger.isBefore && Trigger.isInsert) {
for(Account acc : Trigger.new) {
// Validation logic here
}
}
}
// Pattern 2: Helper Class (common)
trigger AccountTrigger on Account (before insert, after update) {
if(Trigger.isBefore && Trigger.isInsert) {
AccountTriggerHelper.handleBeforeInsert(Trigger.new);
}
}
// Pattern 3: Handler Class (recommended OOTB pattern)
trigger AccountTrigger on Account (before insert, after update) {
new AccountTriggerHandler().execute();
}Pros & Cons Comparison
| Feature | KernDX Trigger Action Framework | OOTB Trigger Patterns (Helper/Handler) |
|---|---|---|
| Execution Order | Metadata-controlled via Order__c field | Manual control with if/else ordering or method calls |
| Modular Actions | Small, focused classes implementing interfaces | Manually create helper/handler classes |
| Configuration | Enable/disable actions via metadata without deployment | Requires code deployment to change behaviour |
| Bypass Control | Global, object-level, and action-level bypass | Must manually implement bypass logic (static flags) |
| Recursion Prevention | Built-in. TRG_Dispatcher tracks the execution stack and short-circuits re-entrant actions | Must manually implement recursion tracking (static flags) |
| Entry Criteria | Declarative formula evaluation per action | Must code all conditional logic in if statements |
| Reusability | Same action class on multiple objects via metadata | Can create reusable helpers, but requires manual wiring |
| Testing | Test each action independently | Typically test entire trigger flow together |
| Feature Flag Gating | RequiredFeatureFlag__c field for conditional execution | Must manually check permissions in code |
| Data Caching | Actions share cached data via execution order | Must manually implement caching patterns |
| Setup Complexity | Requires custom metadata configuration + framework knowledge | Familiar trigger pattern, no extra configuration |
| Performance | Framework overhead (metadata queries, reflection) | Direct method calls, minimal overhead |
| Learning Curve | Must learn framework patterns and interfaces | Standard Apex patterns familiar to most developers |
When to Use KernDX Trigger Action Framework
- Enterprise orgs with multiple trigger requirements
- Complex trigger logic requiring ordered execution
- Frequent changes to trigger behaviour without deployments
- Multiple developers working on same object triggers
- Reusable validation logic across multiple objects
- Bypass requirements for data loads, integrations, testing
- Declarative control over trigger execution
- Entry criteria formulas to conditionally execute logic
When to Use OOTB Trigger Patterns
- Simple triggers with minimal logic
- One-time implementations that rarely change
- Maximum performance is critical (avoid framework overhead)
- Throwaway logic with simple, fixed requirements that will never be reordered or reused
- Prototype/POC projects
- Team familiarity with traditional trigger patterns
- No metadata configuration desired
Example Comparison
OOTB Standard Trigger (Monolithic):
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
// All logic in one file or scattered across helper classes
if(Trigger.isBefore) {
if(Trigger.isInsert) {
// Validation 1
for(Account acc : Trigger.new) {
if(String.isBlank(acc.Name)) {
acc.Name.addError('Name is required');
}
}
// Validation 2
for(Account acc : Trigger.new) {
if(acc.AnnualRevenue < 0) {
acc.AnnualRevenue.addError('Revenue cannot be negative');
}
}
// Field population
for(Account acc : Trigger.new) {
acc.Description = generateAccountReference();
}
}
if(Trigger.isUpdate) {
// Update logic...
}
}
if(Trigger.isAfter) {
// After logic...
}
}KernDX Trigger Action (Modular):
// Physical trigger (one line)
trigger TRG_Account on Account (before insert, before update, after insert, after update)
{
new TRG_Dispatcher().run();
}
// Validation Action 1
public inherited sharing class TRG_AccountValidateName extends TRG_Base
implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<Account> newAccounts)
{
for(Account account : newAccounts)
{
if(String.isBlank(account.Name))
{
account.Name.addError('Name is required');
}
}
}
}
// Validation Action 2
public inherited sharing class TRG_AccountValidateRevenue extends TRG_Base
implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<Account> newAccounts)
{
for(Account account : newAccounts)
{
if(account.AnnualRevenue < 0)
{
account.AnnualRevenue.addError('Revenue cannot be negative');
}
}
}
}
// Field Population Action
public inherited sharing class TRG_AccountPopulateNumber extends TRG_Base
implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<Account> newAccounts)
{
for(Account account : newAccounts)
{
account.Description = generateAccountReference();
}
}
}
// Metadata Configuration (TriggerAction__mdt):
// 1. TRG_AccountValidateName (Order__c: 10)
// 2. TRG_AccountValidateRevenue (Order__c: 20)
// 3. TRG_AccountPopulateNumber (Order__c: 30)
// Enable/disable actions without code deployment!
// Update TriggerAction__mdt.BypassExecution__c = trueKey Advantages Demonstrated:
- Each action is independently testable
- Execution order controlled by metadata
- Actions can be enabled/disabled without deployment
- Actions can be reused on other objects
- New actions can be added without modifying existing code
- Bypass control at action, object, or global level
Legacy Handler Pattern
If you see older TRH_* handler classes in the package, they are kept only so existing code keeps working. They have been replaced by the Trigger Action Framework, so write all new logic with the TRG_* pattern.
What are the moving parts?
Core Classes
TRG_Base
Every trigger action extends this base class. It saves you from rewriting the same plumbing in each action: it hands you the records being changed, tells you which trigger event you are in, and gives you the methods that turn actions on and off. In short, it provides the common wiring and trigger context access:
global virtual inherited sharing class TRG_Base
{
// Protected properties accessible to subclasses
global protected String sObjectName { get; private set; }
global protected TriggerOperation context { get; private set; }
global protected List<SObject> triggerNew { get; private set; }
global protected List<SObject> triggerOld { get; private set; }
// Main execution method
global void run()
// Bypass control methods (object-level)
global static void bypass(SObjectType sObjectType) // type-safe
global static void bypass(String sObjectName) // string-based
global static void clearBypass(SObjectType sObjectType)
global static void clearBypass(String sObjectName)
global static Boolean isBypassed(SObjectType sObjectType)
global static Boolean isBypassed(String sObjectName)
global static void clearAllBypasses()
// Bypass control methods (action-level)
global static void bypassAction(String actionClassName)
global static Boolean isActionBypassed(String actionClassName)
global static void clearActionBypass(String actionClassName)
global static void clearAllActionBypasses()
// Audit-trail helper: attach a reason to subsequent bypass events
global static void setBypassReason(String reason)
// Enum returned by resolveBypassAction(String) for audit-trail parsing
global enum BypassAction { BYPASS, CLEAR, CLEAR_ALL }
}Key Features:
- Automatically populates trigger context properties
- Validates execution only within trigger context
- Provides object-level bypass control
- Calls appropriate interface method based on trigger operation
TRG_Dispatcher
This is the one class your trigger file calls. It reads your configuration, works out which actions apply to the current object and event, and runs them in order, so you never wire that up by hand:
global inherited sharing class TRG_Dispatcher
{
// Entry point for trigger execution
global void run()
}Key Features:
- Reads
TriggerAction__mdtandTriggerSetting__mdtconfiguration - Executes actions in Order__c sequence
- Evaluates entry criteria formulas per record
- Manages recursion detection per action
- Supports self-initiated vs non-self-initiated control
Note: Bypass control methods (
bypass,clearBypass,isBypassed,bypassAction,clearActionBypass,clearAllBypasses,clearAllActionBypasses) are onTRG_Base, notTRG_Dispatcher.
Interfaces
IF_Trigger
There is one interface per trigger event. Your action implements the one (or ones) that match when it should run, and that single choice is what tells the framework where to invoke it:
global interface BeforeInsert { void beforeInsert(List<SObject> newRecords); }
global interface AfterInsert { void afterInsert(List<SObject> newRecords); }
global interface BeforeUpdate { void beforeUpdate(List<SObject> newRecords, List<SObject> oldRecords); }
global interface AfterUpdate { void afterUpdate(List<SObject> newRecords, List<SObject> oldRecords); }
global interface BeforeDelete { void beforeDelete(List<SObject> oldRecords); }
global interface AfterDelete { void afterDelete(List<SObject> oldRecords); }
global interface AfterUndelete { void afterUndelete(List<SObject> newRecords); }Key Characteristics:
- Each interface has a single method
- Methods receive lists of SObjects (bulk processing)
- newRecords/oldRecords naming matches Trigger context variables
- Actions can implement multiple interfaces
Custom Metadata Types
TriggerSetting__mdt
One record per object you want the framework to manage. It is the switchboard for that object: it turns the framework on, and sets org-wide options such as bypass and masking that apply to every action on the object.
Every TriggerSetting__mdt field
| Field | Type | Purpose |
|---|---|---|
SObjectType__c | MetadataRelationship(EntityDefinition) | The SObject this trigger setting applies to |
BypassExecution__c | Checkbox | Bypass all actions for this object |
BypassFeatureFlag__c | MetadataRelationship(FeatureFlag__mdt) | Feature Flag that bypasses all actions when enabled |
RequiredFeatureFlag__c | MetadataRelationship(FeatureFlag__mdt) | Feature Flag required for actions to run |
ApplyMasking__c | Checkbox | Mask configured sensitive fields before actions run |
TriggerAction__mdt
One record per piece of logic. Each row points at an action (an Apex class or a flow), says which trigger event it runs in, sets its run order, and carries its own options such as conditions and bypass:
Every TriggerAction__mdt field
| Field | Type | Purpose |
|---|---|---|
ApexClassName__c | Text(100) | Fully qualified Apex class name. Set this for Apex actions; leave blank for flow actions (the framework dispatches to its built-in flow runner when FlowName__c is populated). |
FlowName__c | Text(80) | Bare flow API name for flow-based actions. Leave blank for Apex actions. |
FailureAction__c | Picklist | How the dispatcher handles uncaught errors raised by the action. LogAndContinue (default) emits a log entry and lets DML proceed; BlockDml calls record.addError(...) to halt the save. Applies to Apex and flow actions alike. |
Event__c | Picklist (Required) | Trigger event: Before Insert, After Insert, Before Update, After Update, Before Delete, After Delete, After Undelete |
TriggerSetting__c | MetadataRelationship(TriggerSetting__mdt) | Link to the parent TriggerSetting for the target SObject |
Order__c | Number(4,0) | Execution order (lower = earlier) |
AllowRecursion__c | Checkbox | Allow recursive execution |
AllowNonSelfInitiated__c | Checkbox | Allow execution from other triggers |
BypassExecution__c | Checkbox | Runtime bypass flag |
BypassFeatureFlag__c | MetadataRelationship(FeatureFlag__mdt) | Feature Flag that bypasses this action when enabled |
RequiredFeatureFlag__c | MetadataRelationship(FeatureFlag__mdt) | Feature Flag required for this action to run |
EntryCriteriaFormula__c | Long Text Area | Formula for conditional execution |
EntryCriteriaContextClassName__c | Text(100) | Context class for formula evaluation (auto-detected for standard objects) |
Description__c | Long Text Area | Required description |
ForcePerformanceLogging__c | Checkbox | Always log performance for this action |
SuppressPerformanceLogging__c | Checkbox | Never log performance for this action |
PerformanceThresholdMs__c | Number(8,0) | Log if duration exceeds threshold (ms) |
Validation rule:
MutuallyExclusiveTargetrequires exactly one of(ApexClassName__c, FlowName__c)to be populated. A row with both populated is ambiguous; a row with neither has nothing to run. The deploy fails if either rule is broken.
How do I configure this?
TriggerSetting__mdt Field Reference
Each field below is tagged with who can change it: (Subscriber Controlled) means you can edit the value in your own org, and (Developer Controlled) means the framework sets it and your org cannot change it.
SObjectType__c (Required)
Purpose: Identifies which SObject this setting applies to via a MetadataRelationship to EntityDefinition.
Format: Select the SObject from the EntityDefinition lookup (e.g., Account, Contact, Foobar__c).
Example:
SObject Type: AccountUsage: Referenced by TriggerAction__mdt records via TriggerSetting__c to associate actions with objects. Use SObjectType__r.QualifiedApiName in SOQL to resolve the API name.
BypassExecution__c (Subscriber Controlled)
Purpose: Runtime bypass for all trigger actions on this object.
Default: false
When to Use:
- Data migrations requiring trigger bypass
- Bulk operations where validation should be skipped
- Temporary disablement during maintenance
Example:
Bypass Execution: trueEffect: All trigger actions for this object are skipped.
BypassFeatureFlag__c (Subscriber Controlled)
Purpose: Feature Flag that bypasses all actions when enabled for the running user.
Type: MetadataRelationship, a lookup to a FeatureFlag__mdt record (e.g., Bypass_Account_Triggers). Selected via dropdown picker in the CMDT UI.
When to Use:
- Emergency kill switches for trigger logic
- Targeted bypass for specific user groups via Feature Flag strategies
- Data migration processes with controlled bypass
Example:
Bypass Feature Flag: Bypass_Account_TriggersEffect: When the referenced Feature Flag evaluates to true for the running user, all trigger actions for this object are skipped.
Tip: For permission-based bypass, create a Feature Flag with a
Custom Permissionstrategy type inFeatureFlagStrategy__mdt.
RequiredFeatureFlag__c (Subscriber Controlled)
Purpose: Feature Flag required for actions to execute.
Type: MetadataRelationship, a lookup to a FeatureFlag__mdt record (e.g., Enable_Account_Validation). Selected via dropdown picker in the CMDT UI.
When to Use:
- Gradual rollout of new trigger logic
- Restrict trigger execution to targeted user groups
- Enable features only for specific profiles or permission set groups
Example:
Required Feature Flag: Enable_Account_ValidationEffect: Actions only execute if the referenced Feature Flag evaluates to true for the running user.
Tip: For permission-based gating, create a Feature Flag with a
Custom Permissionstrategy type inFeatureFlagStrategy__mdt.
EnablePerformanceLogging__c (Subscriber Controlled)
Purpose: Enable performance logging for all trigger actions on this object.
Default: false
When to Use:
- Monitor trigger performance for high-volume objects
- Identify slow actions during optimisation efforts
- Stricter monitoring than global settings
Example:
Enable Performance Logging: trueEffect: Overrides global LogSetting__c.EnableTriggerPerformanceLogging__c for this object.
PerformanceThresholdMs__c (Subscriber Controlled)
Purpose: Threshold in milliseconds for logging trigger action performance.
Default: Inherits from LogSetting__c.TriggerPerformanceThresholdMs__c
When to Use:
- Set stricter thresholds for critical objects
- Override global threshold for specific objects
Example:
Performance Threshold Ms: 200Effect: Actions on this object log when duration exceeds this threshold (if performance logging is enabled).
See Also: Performance Logging for detailed configuration and usage.
ApplyMasking__c (Subscriber Controlled)
Purpose: Runs the data masking pass on this object before any trigger actions execute, so configured sensitive fields are redacted on save.
Default: true. Every object with a Trigger Setting is masked automatically. Uncheck it to opt one object out.
How it runs: On every before-insert and before-update, the dispatcher masks the records before the first trigger action fires, so downstream actions, and the database, only ever see the masked values. The pass runs only when the MaskingFramework_Enabled feature flag is on, and only redacts fields you have wired up with a MaskingTarget__mdt record. It runs in the before phase and writes onto the record in memory, so it adds no extra DML.
When to Use:
- Leave it checked for objects that hold sensitive text (the default).
- Uncheck it for objects with no sensitive data to skip the masking pass for a small performance saving.
Example:
Apply Masking: trueEffect: Sensitive values matching your masking rules are redacted on this object before any trigger action (Apex or flow) sees them, and before the record is committed.
See Also: Data Masking in the Security Guide for rule and target configuration, the honest caveats (masking is destructive, text-only, and not retroactive), and the Data Masking Advisor.
TriggerAction__mdt Field Reference
ApexClassName__c
Purpose: Fully qualified name of the trigger action class. Set this for Apex actions; leave blank for flow-based actions (set FlowName__c instead).
Format:
- Unmanaged:
TRG_SetExternalReference - Managed:
namespace__TRG_SetExternalReferenceornamespace.TRG_SetExternalReference
Validation:
- Exactly one of
ApexClassName__corFlowName__cmust be populated (enforced by theMutuallyExclusiveTargetvalidation rule). - When set, the class must exist and implement the appropriate IF_Trigger interface for the configured
Event__c.
Example:
Apex Class Name: TRG_SetExternalReferenceEvent__c (Required)
Purpose: Specifies the trigger event this action responds to.
Type: Restricted picklist with the following values:
Before InsertAfter InsertBefore UpdateAfter UpdateBefore DeleteAfter DeleteAfter Undelete
Rules:
- Exactly one event per TriggerAction__mdt record
- To run the same action in multiple contexts, create multiple TriggerAction__mdt records (one per event)
Example:
Event: Before InsertTriggerSetting__c (Required)
Purpose: Links this action to a TriggerSetting__mdt record (and therefore to a specific SObject).
Type: MetadataRelationship to TriggerSetting__mdt
Rules:
- Must reference an existing TriggerSetting__mdt record
- The referenced TriggerSetting determines which SObject this action applies to
Example:
Trigger Setting: Foobar (references TriggerSetting__mdt for Foobar__c)Order__c (Required)
Purpose: Controls execution sequence within the same trigger context.
Format: Number (recommended: 10, 20, 30, etc.)
How It Works:
- Lower numbers execute first
- Actions that share the same Order__c run in a stable, predictable sequence, ordered by their developer name, so the order stays the same across deployments
- Allows gaps for inserting actions later without renumbering
Caching Pattern:
Order 10: TRG_CacheRelatedData (loads data for later actions)
Order 20: TRG_ValidateFields (uses cached data)
Order 30: TRG_PopulateFields (uses cached data)
Order 40: TRG_CreateRelatedRecords (final operations)Example:
Order: 10AllowRecursion__c (Developer Controlled)
Purpose: Controls whether action can execute recursively.
Default: true
When true:
- Action can run again even while an earlier run of it is still in progress
- Use when action doesn't cause DML on same object
When false:
- The framework skips the action while an earlier run of it is still in progress (a re-fire set off by its own DML, directly or through a chain of other triggers)
- The action still runs for separate, later operations in the same transaction
- Prevents infinite loops
- Use when action might trigger itself
Example:
Allow Recursion: falseAllowNonSelfInitiated__c (Developer Controlled)
Purpose: Controls whether action executes when called from another trigger.
Default: true
When true:
- Action executes even when triggered by another object's trigger
- Example: Contact action runs when Account trigger creates Contacts
When false:
- Action only executes for direct DML on the object
- Prevents cascading trigger execution
Example:
Allow Non Self-Initiated: trueBypassExecution__c (Subscriber Controlled)
Purpose: Runtime bypass for this specific action.
Default: false
When to Use:
- Temporarily disable problematic action
- A/B testing different action configurations
- Gradual rollout of new functionality
Example:
Bypass Execution: trueEntry Criteria Fields (Developer Controlled)
EntryCriteriaFormula__c
Purpose: Formula evaluated per record for conditional execution.
Format: Formula expression using context class properties
How It Works:
- Framework instantiates context class
- Sets newRecord/oldRecord properties
- Evaluates formula using FormulaEval
- Only records where formula = true are passed to action
Example:
Entry Criteria Formula: isPremiumTier && statusChangedEntryCriteriaContextClassName__c
Purpose: Apex class providing properties for formula evaluation.
Format: Fully qualified class name (blank for standard objects with pre-built contexts)
Auto-detection for Supported Objects:
For the following objects, you can leave this field blank - the framework automatically uses pre-built context classes from UTIL_FormulaContext:
- Account, Campaign, Case, Contact, Event, Foobar__c, Lead, Opportunity, Task, User
These pre-built contexts provide oldRecord and newRecord typed to the specific SObject, supporting formulas like:
newRecord.Industry <> oldRecord.IndustryCustom Context Classes (for custom objects or advanced business logic):
Requirements:
- Must be declared
global(required for managed package visibility) - Must implement
UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContextinterface - Must implement the
setContext(SObject oldRecord, SObject newRecord)method - Must have
globalproperties referenced in formula - Should have oldRecord and newRecord properties (typed to specific SObject)
- Should use
@SuppressWarnings('PMD.AvoidGlobalModifier')annotation
Example:
Entry Criteria Context Class Name: UTIL_AccountContextContext Class Example:
/**
* @description Formula evaluation context for Account records. Provides old and new record states
* for formula-based filtering and evaluation in trigger and automation contexts.
*
* @see UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContext
*
* @author <your-author-tag>
*
* @date January 2025
*/
@SuppressWarnings('PMD.AvoidGlobalModifier')
global with sharing class UTIL_AccountContext implements UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContext
{
/**
* @description The Account record state before the DML operation
*/
global Account oldRecord;
/**
* @description The Account record state after the DML operation
*/
global Account newRecord;
/**
* @description Checks if the account is premium tier
*/
global Boolean isPremiumTier
{
get { return newRecord?.Type == 'Premium'; }
}
/**
* @description Checks if the status field changed
*/
global Boolean statusChanged
{
get { return oldRecord != null && newRecord?.Status__c != oldRecord.Status__c; }
}
/**
* @description Sets the context for formula evaluation with old and new Account records
*
* @param oldSObject The record state before the DML operation
* @param newSObject The record state after the DML operation
*/
public void setContext(SObject oldSObject, SObject newSObject)
{
oldRecord = (Account)oldSObject ?? new Account();
newRecord = (Account)newSObject ?? new Account();
}
}Description__c (Required)
Purpose: Documents what the action does.
Validation: Required by validation rule.
Best Practices:
- Describe the business logic
- Note any dependencies
- Mention data requirements
Example:
Description: Populates ExternalReference__c field with a UUID if the field is blank.
This allows bulk imports to preset values while ensuring all records have a unique identifier.Flow as a Trigger Action
Sometimes the logic you want to add is easier to build in Flow than in Apex. You can run an auto-launched Flow as a trigger action and have it behave exactly like an Apex action, with the same run order, bypass, recursion control, performance monitoring, audit logging, and feature-flag gating.
To register a flow, deploy a TriggerAction__mdt row with FlowName__c populated and ApexClassName__c left blank. The framework then dispatches to its built-in flow runner: it constructs the flow interview, supplies the trigger record, runs the flow, and copies the populated fields back onto the trigger record. You write no Apex to wire it up.
For a copy-paste quick start see the Fast Start - Trigger Actions walkthrough. The decision criteria, error strategies, audit gating, bypass / rollback options, and TAF migration recipe live in this guide below.
TriggerAction__mdt fields used by flow actions
| Field | Type | Required | Default | Allowed values | Purpose |
|---|---|---|---|---|---|
FlowName__c | Text(80) | Yes (XOR with ApexClassName__c) | — | Bare flow API name, no namespace prefix | Flow API name bound to this action. The framework resolves the namespace internally. Validated at deploy by the npm run scan:flow-references CI scanner rule (existence + active state + variable contract). |
ApexClassName__c | Text(100) | Leave blank for flow actions | — | — | Leave this field blank when configuring a flow action. The MutuallyExclusiveTarget validation rule rejects rows with both fields populated. |
FailureAction__c | Picklist | No (defaulted) | LogAndContinue | LogAndContinue, BlockDml | How the framework handles uncaught flow errors. LogAndContinue (default) emits a LogEntryEvent__e and lets DML proceed; BlockDml calls record.addError(...) to stop the save and surface the flow error to the user. See Failure-action strategies; the field applies to Apex actions too. |
Variable contract on the registered flow
The framework owns the flow's input/output variable map. Every flow registered as a trigger action declares one or two variables, exactly as listed below:
| Variable | Type | Direction | Required for |
|---|---|---|---|
record | The trigger object (e.g. Account) | Input + Output | All 7 contexts |
recordPrior | The trigger object (e.g. Account) | Input only | Before Update / After Update only |
The variable type must match the dispatching TriggerSetting__mdt.SObjectType__c exactly. A flow whose record variable is typed Contact cannot be registered against an Account trigger setting, because a type mismatch cannot be recovered from at runtime. The deploy-time CI scanner rule (npm run scan:flow-references) catches this before merge by reading the active flow's Metadata.variables via Tooling API and comparing the objectType to the dispatching trigger setting.
LogSetting__c volume gate
| Field | Type | Default | Allowed values | Purpose |
|---|---|---|---|---|
LogSetting__c.EnableFlowActionLogging__c | Text(40) | ErrorsOnly | Off, ErrorsOnly, AlwaysOn | Controls how often the framework writes flow-action audit log entries. Off disables audit logging entirely. ErrorsOnly (default) logs one entry per failed flow run, with the failed record identified. AlwaysOn logs one summary entry per successful batch plus one entry per failed record. Use this only in orgs that need compliance-grade evidence of every flow trigger run. |
The volume gate is layered per-user in the same way as the existing LogSetting__c.LogLevelThreshold__c field: each user can have their own LogSetting__c record overriding the org-default. Performance logging is controlled separately via the existing LogSetting__c.TriggerPerformanceThresholdMs__c field.
Mock harness
In tests you do not want to run a real flow. TST_InvokeFlowMock.forFlow(name)...register() short-circuits Flow.Interview at test time. See Fast Start - Trigger Actions: Testing Flow Actions with TST_InvokeFlowMock for the helper reference and the worked example.
When to use a flow action vs a record-triggered flow
Both options run a flow in response to a DML event but sit in different layers of the platform. The deciding question is whether the flow needs to interleave with Apex trigger actions (ordering, shared recursion blocking, shared bypass, shared audit) or run alongside them as an independent unit. Interleaving is the flow-action use case.
| Use case | Recommendation |
|---|---|
| Mixed Apex + Flow logic that needs to run under one bypass / recursion / audit / perf umbrella | Flow action (FlowName__c populated) |
| Pure-Flow logic with no need for ordered interleaving with Apex | Native record-triggered flow |
Validation flows that must respect existing UTIL_ValidationRule bypass / group-of-rules wiring | Flow action with FailureAction__c = 'BlockDml' |
| Async / scheduled-path Flow logic | Native record-triggered flow (a flow action runs synchronously inside the trigger) |
| Flow needs to abort the save with a custom message | Flow action with FailureAction__c = 'BlockDml' (the framework copies the flow's error to record.addError(...) with the flow name and record id appended) |
| Flow only updates the same record's fields and runs once per save | Either one: a flow action if other Apex actions are already configured on the object; a native record-triggered flow otherwise |
Failure-action strategies
When an action throws an error, you need to decide whether the user's save should still go through or be stopped. FailureAction__c is that decision, set per row, and it applies to every trigger action (Apex and flow alike). The dispatcher wraps each action invocation in a try/catch and routes any uncaught Exception through the configured policy.
| Strategy | Behaviour on action error | Recommended for |
|---|---|---|
LogAndContinue (default) | Emits a LogEntryEvent__e via LOG_Builder with LogLevel__c = ERROR, the action identity, the record id, and the original error message. DML proceeds. | Orchestration, derived-field population, notifications, supplemental logging: anything where a single action's failure should be visible but should not block the user's save. |
BlockDml | Calls record.addError(formattedMessage) to block DML. Surfaces the error to the user. | Validation actions where the trigger should reject records that fail the action's checks. |
The formatted error message for flow actions has the shape:
[{flowName}] {originalActionError} (record: {recordId})The action-identity prefix lets a user (or admin debugging from the Setup audit log) identify which TriggerAction__mdt row produced the error. The record id suffix correlates against the LogEntry__c audit history for the same record.
Governor exceptions always rethrow.
System.LimitException(CPU, heap, SOQL row limits) is rethrown regardless ofFailureAction__c. The dispatcher only intercepts business-logic exceptions; it never swallows a governor breach.
Bulk behaviour and governor limits
The framework starts one flow interview per record, synchronously, inside the trigger. That sounds expensive for a 200-record data load, but Salesforce batches flow element execution across the interviews in a transaction: when the same flow runs for many records, a Get Records element issues one query in total, not one per record, and the same batching applies to Create and Update elements. Measured against the shipped package in a subscriber org, query and DML statement counts stayed constant whether a save carried 1, 10, or 100 records.
Two practical rules follow:
- Keep queries and DML out of Loop elements. The batching applies across interviews, not across the iterations of a Loop inside your flow. A Get Records or Update Records element inside a Loop body runs once per iteration, exactly as it would in a native record-triggered flow. Work on the whole collection outside the loop instead.
- Watch CPU time, not statement counts. Each interview adds a few milliseconds of CPU per record per flow action, so one flow action on a 200-record save spends well under a second of the 10-second CPU allowance. The cost only becomes interesting when several flow actions stack on one high-volume object. Set
PerformanceThresholdMs__con the row so slow runs surface in the performance log, and move genuinely heavy logic to an Apex action on the same registration row.
Failure isolation stays per record in bulk: a record whose interview throws is logged (or blocked) on its own via FailureAction__c, and the other records in the save carry on.
Cross-namespace flow resolution
FlowName__c accepts a bare flow API name, never a namespace-qualified name. The framework works out the namespace itself and calls the namespace-aware form Flow.Interview.createInterview(namespace, flowName, variables), which returns the active flow whether it lives in the kern namespace or in your own namespace.
- A
FlowName__cvalue ofAccount_SetDefaultsresolves to the flow in your own namespace when the dispatching CMDT row is in your package, and to the kern-namespace flow when the row is in the kern package. Both work from the same field shape. - Authors should not prefix
FlowName__cwithkern.or any other namespace. A namespace prefix in this field produces a deploy-time scanner error and a runtime configuration error.
Recommended-default pattern matrix
When you ship a flow-action row, default these fields based on the flow's purpose:
| Flow purpose | FailureAction__c | AllowRecursion__c | Notes |
|---|---|---|---|
| Orchestration (set defaults, populate derived fields, send notifications) | LogAndContinue | true | Default shape: production-resilient, audit-visible. |
| Validation (reject records that fail business rules) | BlockDml | true | Surfaces the flow's error to the user. |
| Cascading update (writes to related records) | LogAndContinue | false | Block recursion on the originating object to avoid unintended self-fire. |
Audit volume control
Every flow-action invocation can emit a LogEntryEvent__e audit entry. You control how much it logs with LogSetting__c.EnableFlowActionLogging__c, a 3-state field set for the org or layered per-user (it mirrors the existing LogLevelThreshold__c precedent on the same custom setting):
| Value | Success path | Error path | When to use |
|---|---|---|---|
Off | No emission | No emission | Integration-only orgs where audit volume is excessive and external observability already covers the path. |
ErrorsOnly (default) | No emission | One entry per failed record (with RecordId__c set, queryable per-record) | Production default: bounded volume, with a kept record of every failure. |
AlwaysOn | One per-batch summary entry per dispatch (record ids serialised into ContextData__c.recordIds, RecordId__c = null) | One entry per failed record | High-regulation orgs where every flow trigger run must be evidenced. Heavy DML multiplies entry volume, so measure before flipping. |
Performance logging is separate from audit logging and is always sparse (only emits when an action exceeds its PerformanceThresholdMs__c). The two log streams can be queried independently, and the perf log entry includes flowName as a context dimension so you can filter perf logs by flow.
Bypass and rollback
Two options, in priority order from most-surgical to most-broad:
- Per-row declarative bypass (recommended for surgical disable). Set
BypassExecution__c = trueon the specificTriggerAction__mdtrow. One-row CMDT deploy disables this flow action and only this flow action. - Per-row feature-flag bypass (recommended for staged rollout). Set the row's
RequiredFeatureFlag__cto aFeatureFlag__mdtrecord and flip itsIsEnabledByDefault__ctofalse. This is useful when several flow actions need to be gated together: point them all at the same feature flag and flip the flag once.
Per-flow surgical bypass via Apex (e.g. bypassFlow('Account_SetDefaults')) is not provided. To disable a specific flow action, use option 1 or option 2 above.
Migrating from TAF to KernDX
If you already run flows through the Trigger Action Flow (TAF) library, moving them to KernDX is low-effort. Both frameworks invoke a flow once per record and follow the same authoring contract: the auto-launched flow declares a record variable matching the trigger object. The flow does not change, and the migration is a metadata-only field rename on the Trigger_Action__mdt records:
<!-- BEFORE (TAF) -->
<values><field>Apex_Class_Name__c</field><value xsi:type="xsd:string">TriggerActionFlow</value></values>
<values><field>Flow_Name__c</field><value xsi:type="xsd:string">Account_SetDefaults</value></values>
<!-- AFTER (KernDX) -->
<values><field>kern__FlowName__c</field><value xsi:type="xsd:string">Account_SetDefaults</value></values>
<values><field>kern__FailureAction__c</field><value xsi:type="xsd:string">LogAndContinue</value></values>Note that kern__ApexClassName__c is omitted from KernDX flow rows: the framework dispatches via its built-in flow runner whenever FlowName__c is populated and ApexClassName__c is blank. Setting both fields fails the MutuallyExclusiveTarget validation rule.
FailureAction__c defaults to LogAndContinue if left blank. Set it explicitly on every record so future readers don't have to look up framework defaults to know how the row will behave.
Change Data Capture Actions
Sometimes you need to react to a change after it has been saved, including changes that did not come through the normal UI: a Bulk API job, an integration, or another package. Change Data Capture publishes a Change Event whenever a record is created, updated, deleted, or undeleted. KernDX runs trigger actions on those Change Events through the same framework you already use for object triggers, so you can respond to any committed change with ordered, bypassable, feature-flag-gated actions written in Apex or Flow.
A Change Event action runs after the change has committed. There is no before-phase and nothing to roll back: the event is simply a notification that the change already happened.
The Change Event trigger
A Change Event entity gets the same one-line trigger file as any object, the one that hands control to the framework. Change Events can only be inserted from your side, so the trigger handles after insert:
trigger TRG_FoobarChangeEvent on Foobar__ChangeEvent (after insert)
{
new TRG_Dispatcher().run();
}Each delivered event is one record in the action's incoming list. A single event can represent a change to several records of the same object, because the platform batches changes committed together.
Enabling Change Data Capture: the Change Event entity (
Foobar__ChangeEvent) only exists once CDC is switched on for the object, either in Setup → Change Data Capture or by deploying aPlatformEventChannelandPlatformEventChannelMember. The framework does not enable CDC for you.
Registering a Change Event action
Change Event entities cannot be selected in the SObject Type relationship picklist on TriggerSetting__mdt, because the platform excludes them from that lookup. Register the setting with the Object API Name Override field instead:
| Field | Value for a CDC setting |
|---|---|
ObjectApiNameOverride__c | The Change Event API name, e.g. Foobar__ChangeEvent. This is what the dispatcher routes on. |
SObjectType__c | Optional. Set it to the source object (Foobar__c) for documentation; the override wins at dispatch time. |
At least one of the two fields must be populated. If both are blank, the deploy fails with the RequireObjectIdentifier validation rule:
Set either SObject Type (for standard objects, custom objects, and platform events) or Object API Name Override (for Change Data Capture entities). At least one must be populated.
Once the setting exists, register TriggerAction__mdt rows against it exactly as you would for any object. Set Event__c to After Insert, and populate either ApexClassName__c (for an Apex action) or FlowName__c (for a flow action).
Reading the change header
Every Change Event carries a header describing what changed: the change type, the affected record IDs, the changed field names, and commit metadata.
Apex actions read the platform header straight off the event record:
public inherited sharing class TRG_FoobarChangeAudit extends TRG_Base implements IF_Trigger.AfterInsert
{
public void afterInsert(List<SObject> changeEvents)
{
for(SObject changeEvent : changeEvents)
{
EventBus.ChangeEventHeader header =
(EventBus.ChangeEventHeader)changeEvent.get('ChangeEventHeader');
LOG_Builder.build()
.info(header.changeType + ' affected ' + header.recordIds.size() + ' record(s)')
.emitAt('TRG_FoobarChangeAudit');
}
}
}Flow actions receive the header as a strongly-typed input variable. Beyond the standard record input (which holds the Change Event itself), a flow registered against a Change Event entity also receives a variable named header of type DTO_ChangeEventHeader. (A DTO is a small class holding exactly the fields you want to read.) Declare an Apex-defined variable on the flow:
- Variable name:
header - Type: Apex-Defined
- Apex Class:
DTO_ChangeEventHeader
The framework copies the platform header into that DTO and populates header before the interview runs, so you write no Apex to bridge it. Read its fields in assignments and decisions, for example header.changeType, header.recordIds, and header.changedFields.
The header exposes the supported subset of the platform's change-event metadata:
| Field | Description |
|---|---|
changeType | CREATE, UPDATE, DELETE, UNDELETE, or a GAP_* replay-gap notification. |
recordIds | The source record IDs this event covers. |
changedFields | The fields whose values changed in this commit. |
entityName | The source object API name (Foobar__c), not the Change Event suffix. |
changeOrigin | The change's origin; useful for filtering out self-initiated changes. |
commitTimestamp / commitUser / commitNumber | Commit metadata. Note commitTimestamp is Unix epoch milliseconds (a Long), not a Datetime. |
transactionKey / sequenceNumber | Correlate changes from the same atomic transaction. |
nulledFields / diffFields | Fields nulled, and large/complex fields that differ from the prior commit. |
See the DTO_ChangeEventHeader reference for the full field list.
Block DML is unavailable for Change Data Capture
FailureAction__c = BlockDml cannot apply to a Change Event action. Block DML works by calling addError(...) to abort an in-progress save, but a Change Event is delivered after the change has already committed, so there is nothing left to block. Two layers keep you from configuring it by accident:
- At deploy: the flow-reference scanner (
npm run scan:flow-references) rejects a Change Event flow row whoseFailureAction__cisBlockDml, and points you toLogAndContinue. (Apex Change Event actions are caught by the runtime layer below.) - At runtime: if such a row reaches dispatch anyway, the framework degrades it to
LogAndContinueand emits a distinct warning audit entry so you can find the misconfiguration:
BlockDml is not supported on Change Event dispatch (cannot roll back a committed change) — degraded to LogAndContinue:
Always use LogAndContinue (the default) for Change Event actions. A failed Change Event action records the error and the dispatch continues; the underlying data is already committed regardless.
Audit volume: when a Change Event flow action emits an audit entry, the affected record IDs are aggregated into a single entry per dispatch rather than one entry per record, so high-throughput CDC does not flood the audit log.
Post-Trigger Actions
Some work should happen once for the whole transaction, not once per object or per record: one audit summary for everything that changed, a single background job for the whole batch, or transaction-wide metrics. That work does not belong inside any one object's trigger action.
A post-trigger action is an Apex class that runs once at the very end of a trigger transaction, after every trigger action on every object has finished. Examples of what to put there: aggregating one audit summary for the whole transaction, enqueuing a single asynchronous job, or emitting transaction-wide telemetry.
You register a post-trigger action with PostTriggerAction__mdt, and it inherits the framework's ordering, feature-flag gating, bypass, and performance monitoring.
When post-trigger actions fire
Post-trigger actions fire once per outermost trigger dispatch:
- Nested DML does not re-fire them. If a trigger action performs DML on another object, that nested dispatch does not run post-actions. The framework fires them only when the outermost dispatch unwinds, so they run exactly once no matter how deeply triggers nest.
- Multiple top-level DML statements fire them multiple times. A transaction with several independent top-level DML statements runs post-actions once for each of them, once as each top-level dispatch completes. Write post-actions to be safe to run more than once in a transaction.
- Asynchronous and platform-event work gets its own firing. A Queueable, future, scheduled job, or platform-event subscriber runs in a fresh transaction, so its triggers produce their own outermost dispatch and their own post-action firing.
The no-DML contract
A post-trigger action must not perform synchronous DML. After your execute(...) returns, the framework checks whether any synchronous DML ran and throws if it did. This always fails the transaction, regardless of the row's Failure Action, because it is a contract violation rather than a runtime fault. The error names the offending class:
Post-trigger action "{class}" performed synchronous DML. Post-trigger actions run once after all trigger actions complete and must not perform synchronous DML (compute, log, or enqueue async only).
To change data from a post-action, use an asynchronous path instead: enqueue a Queueable, future, or scheduled job, or publish a Platform Event. These are the supported ways to write data from a post-action, and they do not trip the guard.
Intended uses and anti-patterns
| Good fit (use a post-trigger action) | Poor fit (use a normal trigger action instead) |
|---|---|
| Aggregating one audit or telemetry summary for the whole transaction | Per-record validation or field defaulting |
| Enqueuing a single async job for everything touched in the transaction | Creating or updating related records (that is synchronous DML) |
| Cross-object orchestration that must run after all object triggers complete | Logic that needs per-record old/new values |
| Transaction-wide metrics or instrumentation | Object-specific business logic |
A post-trigger action has no per-record context (only the set of SObject types whose triggers fired) and cannot do synchronous DML. Anything that needs a specific record's values, or that writes data, belongs in a normal trigger action on the relevant object.
Writing a post-trigger action
Implement IF_Trigger.PostAction:
public inherited sharing class TRG_EmitTransactionAudit implements IF_Trigger.PostAction
{
public void execute(IF_Trigger.PostActionContext context)
{
if(context.touchedSObjectTypes.isEmpty())
{
return;
}
LOG_Builder.build()
.info('Transaction touched ' + context.touchedSObjectTypes.size() + ' SObject type(s)')
.emitAt('TRG_EmitTransactionAudit');
}
}context.touchedSObjectTypes is a read-only Set<SObjectType>: the SObject types whose triggers participated in the transaction. Branch on it to decide what to do:
if(context.touchedSObjectTypes.contains(Account.SObjectType))
{
// Account triggers ran in this transaction — do Account-domain follow-up.
}The set carries types, not record IDs, by design: a post-action runs once for the whole transaction with no per-record context, and type-level scoping keeps it bounded in batch operations while keeping one post-action from accidentally seeing record data that another post-action staged. If you need per-record context, have your trigger actions record it in their own static variable and read that static from the post-action.
Registering a post-trigger action
Create a PostTriggerAction__mdt record. See the PostTriggerAction__mdt reference for the complete field list.
| Field | Purpose |
|---|---|
ApexClassName__c | The class implementing IF_Trigger.PostAction. |
Order__c | Execution sequence (lower runs first). Rows sharing an order value run in a stable, predictable sequence, ordered by their developer name, so the order stays the same across deployments. |
Description__c | What the post-action does and why it exists. |
FailureAction__c | How an unhandled error is handled. Log and Continue (the default) records the error in the audit log and lets the transaction proceed; best for non-critical follow-up such as notifications or telemetry. Block DML rethrows the error so the originating DML rolls back; choose it when the post-action must be guaranteed to complete before any data is committed. Either way, governor-limit exceptions always propagate, and a synchronous-DML violation always fails the transaction regardless of this setting. |
RequiredFeatureFlag__c / BypassFeatureFlag__c | Feature-flag gating: run only when a flag is on, or skip when a flag is on. Same model as trigger actions. |
BypassExecution__c | Unconditional kill switch (a master off-switch): when checked, the post-action is skipped for everyone. |
TriggerSetting__c | Optional scope. Leave blank to fire on every outermost dispatch (cross-object work). Set it to a Trigger Setting to fire only when that setting's object participated in the transaction. |
EntryCriteriaContextClassName__c | Optional Apex evaluator implementing IF_Trigger.PostActionEntryCriteria. Its shouldRun(context) gates the action: return false to skip. Use for conditions richer than the Trigger Setting scope. |
PerformanceThresholdMs__c / ForcePerformanceLogging__c | Per-action performance monitoring: log when the action runs longer than the threshold, or force its timing to be logged every time. Inherits the global default when blank. |
Bypass Execution is applied first, at query time: the framework loads only non-bypassed rows. The surviving rows are then gated in order, cheapest check first, and the framework stops at the first gate that excludes a row: feature flags → Trigger Setting scope → entry-criteria evaluator → run.
An entry-criteria evaluator looks like:
public inherited sharing class TRG_PostActionRequireAccount implements IF_Trigger.PostActionEntryCriteria
{
public Boolean shouldRun(IF_Trigger.PostActionContext context)
{
return context.touchedSObjectTypes.contains(Account.SObjectType)
&& UTIL_FeatureFlag.isEnabled('Account_Followup_Enabled');
}
}Trigger Action Interfaces
IF_Trigger.BeforeInsert
Purpose: Execute logic before records are inserted.
Method Signature:
void beforeInsert(List<SObject> newRecords)Parameters:
newRecords- List of SObjects being inserted (mutable, no IDs)
Use Cases:
- Set default field values
- Populate calculated fields
- Validate data before save
- Cross-record validation
Example:
public inherited sharing class TRG_ValidateAccount extends TRG_Base implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<Account> newAccounts)
{
for(Account account : newAccounts)
{
// Validate: Annual revenue must be positive
if(account.AnnualRevenue != null && account.AnnualRevenue < 0)
{
account.AnnualRevenue.addError('Annual Revenue must be positive');
}
// Default: Set industry if blank
if(String.isBlank(account.Industry))
{
account.Industry = 'Other';
}
}
}
}IF_Trigger.AfterInsert
Purpose: Execute logic after records are inserted.
Method Signature:
void afterInsert(List<SObject> newRecords)Parameters:
newRecords- List of inserted SObjects (read-only, have IDs)
Use Cases:
- Create related records
- Send notifications
- Publish platform events
- Update parent/child records
Example:
public inherited sharing class TRG_CreateWelcomeTask extends TRG_Base implements IF_Trigger.AfterInsert
{
public void afterInsert(List<Account> newAccounts)
{
List<Task> tasksToInsert = new List<Task>();
for(Account account : newAccounts)
{
tasksToInsert.add(new Task(
WhatId = account.Id,
Subject = 'Welcome New Account',
OwnerId = account.OwnerId,
ActivityDate = System.today().addDays(7),
Priority = 'Normal'
));
}
if(!tasksToInsert.isEmpty())
{
DML_Builder.newTransaction().doInsert(tasksToInsert).execute();
}
}
}IF_Trigger.BeforeUpdate
Purpose: Execute logic before records are updated.
Method Signature:
void beforeUpdate(List<SObject> newRecords, List<SObject> oldRecords)Parameters:
newRecords- List of SObjects being updated (mutable)oldRecords- List of SObjects before update (read-only)
Use Cases:
- Track field changes
- Validate changes
- Update audit fields
- Prevent updates under conditions
Example:
public inherited sharing class TRG_TrackStatusChange extends TRG_Base implements IF_Trigger.BeforeUpdate
{
public void beforeUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
for(Integer i = 0; i < newAccounts.size(); i++)
{
Account newAccount = newAccounts.get(i);
Account oldAccount = oldAccounts.get(i);
// Track status changes
if(newAccount.Status__c != oldAccount.Status__c)
{
newAccount.StatusChangeDate__c = System.now();
newAccount.PreviousStatus__c = oldAccount.Status__c;
}
}
}
}IF_Trigger.AfterUpdate
Purpose: Execute logic after records are updated.
Method Signature:
void afterUpdate(List<SObject> newRecords, List<SObject> oldRecords)Parameters:
newRecords- List of updated SObjects (read-only)oldRecords- List of SObjects before update (read-only)
Use Cases:
- Cascade updates to related records
- Create history records
- Send change notifications
- Update rollup summaries
Example:
public inherited sharing class TRG_CascadePhoneUpdate extends TRG_Base implements IF_Trigger.AfterUpdate
{
public void afterUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
Map<Id, String> accountIdToNewPhone = new Map<Id, String>();
// Identify accounts with phone changes
for(Integer i = 0; i < newAccounts.size(); i++)
{
Account newAccount = newAccounts.get(i);
Account oldAccount = oldAccounts.get(i);
if(newAccount.Phone != oldAccount.Phone)
{
accountIdToNewPhone.put(newAccount.Id, newAccount.Phone);
}
}
if(!accountIdToNewPhone.isEmpty())
{
// Query and update contacts using QRY_Builder
List<Contact> contactsToUpdate = QRY_Builder.selectFrom(Contact.SObjectType)
.addFields(new List<SObjectField>{Contact.Id, Contact.AccountId, Contact.Phone})
.condition(Contact.AccountId).isIn(accountIdToNewPhone.keySet())
.toList();
for(Contact contact : contactsToUpdate)
{
contact.Phone = accountIdToNewPhone.get(contact.AccountId);
}
if(!contactsToUpdate.isEmpty())
{
DML_Builder.newTransaction().doUpdate(contactsToUpdate).execute();
}
}
}
}IF_Trigger.BeforeDelete
Purpose: Execute logic before records are deleted.
Method Signature:
void beforeDelete(List<SObject> oldRecords)Parameters:
oldRecords- List of SObjects being deleted (read-only)
Use Cases:
- Prevent deletion under conditions
- Validate deletion is allowed
- Archive data before deletion
Example:
public inherited sharing class TRG_PreventStrategicAccountDeletion extends TRG_Base implements IF_Trigger.BeforeDelete
{
public void beforeDelete(List<Account> oldAccounts)
{
for(Account account : oldAccounts)
{
if(account.Type == 'Strategic' || account.Type == 'Partner')
{
account.addError('Cannot delete Strategic or Partner accounts. Please contact your administrator.');
}
}
}
}IF_Trigger.AfterDelete
Purpose: Execute logic after records are deleted.
Method Signature:
void afterDelete(List<SObject> oldRecords)Parameters:
oldRecords- List of deleted SObjects (read-only)
Use Cases:
- Clean up related records
- Log deletions
- Archive deleted data
- Update summary counts
Example:
public inherited sharing class TRG_LogAccountDeletion extends TRG_Base implements IF_Trigger.AfterDelete
{
public void afterDelete(List<Account> oldAccounts)
{
List<AuditLog__c> logsToInsert = new List<AuditLog__c>();
for(Account account : oldAccounts)
{
logsToInsert.add(new AuditLog__c(
Type__c = 'Account Deletion',
Message__c = 'Account deleted: ' + account.Name,
RecordId__c = account.Id,
DeletedBy__c = UserInfo.getUserId(),
DeletedDate__c = System.now()
));
}
if(!logsToInsert.isEmpty())
{
DML_Builder.newTransaction().doInsert(logsToInsert).execute();
}
}
}IF_Trigger.AfterUndelete
Purpose: Execute logic after records are undeleted.
Method Signature:
void afterUndelete(List<SObject> newRecords)Parameters:
newRecords- List of undeleted SObjects (read-only, have IDs)
Use Cases:
- Restore related records
- Send notifications
- Create review tasks
- Reset fields
Example:
public inherited sharing class TRG_CreateRestoreReviewTask extends TRG_Base implements IF_Trigger.AfterUndelete
{
public void afterUndelete(List<Account> newAccounts)
{
List<Task> tasksToInsert = new List<Task>();
for(Account account : newAccounts)
{
tasksToInsert.add(new Task(
WhatId = account.Id,
Subject = 'Review Restored Account',
OwnerId = account.OwnerId,
Priority = 'High',
ActivityDate = System.today(),
Description = 'This account was restored from the Recycle Bin. Please review and verify data integrity.'
));
}
if(!tasksToInsert.isEmpty())
{
DML_Builder.newTransaction().doInsert(tasksToInsert).execute();
}
}
}Caching with Trigger Actions
When several actions on the same object need the same related data, querying it in each one wastes queries and risks hitting governor limits. Because actions run in a defined order, you can query once and share the result: an early action queries and caches the data, and later actions read from that cache. The result is fewer SOQL queries and faster triggers.
Caching Pattern Overview
Concept: Trigger actions act like functions in a pipeline. An early action can cache data in a static variable, and later actions can access that cache.
Two ways to hold the cache. The pattern below keeps the cache in typed static variables on the caching action class, which gives later actions strongly typed getters and is the right choice for structured record data. For simple values where a holder class is overkill, the framework already ships a transaction-scoped store: UTIL_Cache.inTransaction() returns a name-keyed store (put, get, contains, remove) whose contents live only for the current transaction and are shared by every caller in it.
Benefits:
- Avoid SOQL in loops
- Reduce total SOQL queries
- Share data between multiple actions
- Improve performance
- Maintain bulkification
Implementation Pattern
Step 1: Create Caching Action
/**
* @description Caches related Contact data for Account trigger actions.
* This action runs first (Order: 10) to load data for later actions.
*
* @see TRG_CacheAccountContacts_TEST
*
* @author <your-author-tag>
*
* @group Trigger Actions
*
* @date January 2025
*/
public inherited sharing class TRG_CacheAccountContacts extends TRG_Base implements
IF_Trigger.BeforeUpdate,
IF_Trigger.AfterUpdate
{
/**
* @description Static cache shared across all actions in this transaction.
* Key: Account Id
* Value: List of Contacts for that Account
*/
@TestVisible
private static Map<Id, List<Contact>> contactsByAccountId;
/**
* @description Queries and caches Contact data before update context.
*
* @param newAccounts Accounts being updated
* @param oldAccounts Accounts before update
*/
public void beforeUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
loadContactCache(newAccounts);
}
/**
* @description Queries and caches Contact data after update context.
*
* @param newAccounts Updated accounts
* @param oldAccounts Accounts before update
*/
public void afterUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
loadContactCache(newAccounts);
}
/**
* @description Loads Contact data into static cache for use by other actions.
*
* @param accounts Accounts to cache contacts for
*/
private static void loadContactCache(List<Account> accounts)
{
contactsByAccountId = new Map<Id, List<Contact>>();
Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
// Query all contacts for these accounts
List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
.addFields(new List<SObjectField>{Contact.Id, Contact.AccountId, Contact.FirstName, Contact.LastName, Contact.Email, Contact.Phone, Contact.Title})
.condition(Contact.AccountId).isIn(accountIds)
.toList();
// Build cache map
for(Contact contact : contacts)
{
if(!contactsByAccountId.containsKey(contact.AccountId))
{
contactsByAccountId.put(contact.AccountId, new List<Contact>());
}
contactsByAccountId.get(contact.AccountId).add(contact);
}
}
/**
* @description Public getter for other actions to access cached data.
*
* @param accountId The Account Id to get contacts for
*
* @return List of Contacts for the account, or empty list if none
*/
public static List<Contact> getCachedContacts(Id accountId)
{
if(contactsByAccountId == null || !contactsByAccountId.containsKey(accountId))
{
return new List<Contact>();
}
return contactsByAccountId.get(accountId);
}
/**
* @description Clears the cache (useful for testing).
*/
@TestVisible
private static void clearCache()
{
contactsByAccountId = null;
}
}Step 2: Create Actions That Use Cache
/**
* @description Validates that premium accounts have at least one contact.
* Uses cached Contact data from TRG_CacheAccountContacts.
*
* @see TRG_ValidatePremiumAccountContacts_TEST
*
* @author <your-author-tag>
*
* @group Trigger Actions
*
* @date January 2025
*/
public inherited sharing class TRG_ValidatePremiumAccountContacts extends TRG_Base implements IF_Trigger.BeforeUpdate
{
/**
* @description Validates premium accounts have contacts.
* Consumes data from TRG_CacheAccountContacts cache.
*
* @param newAccounts Accounts being updated
* @param oldAccounts Accounts before update
*/
public void beforeUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
for(Account account : newAccounts)
{
if(account.Type == 'Premium')
{
// Get cached contacts (no SOQL query!)
List<Contact> contacts = TRG_CacheAccountContacts.getCachedContacts(account.Id);
if(contacts.isEmpty())
{
account.addError('Premium accounts must have at least one contact. Please add a contact before changing to Premium tier.');
}
}
}
}
}/**
* @description Updates Account summary fields based on Contact data.
* Uses cached Contact data from TRG_CacheAccountContacts.
*
* @see TRG_UpdateAccountContactSummary_TEST
*
* @author <your-author-tag>
*
* @group Trigger Actions
*
* @date January 2025
*/
public inherited sharing class TRG_UpdateAccountContactSummary extends TRG_Base implements IF_Trigger.BeforeUpdate
{
/**
* @description Updates contact count and primary contact name.
* Consumes data from TRG_CacheAccountContacts cache.
*
* @param newAccounts Accounts being updated
* @param oldAccounts Accounts before update
*/
public void beforeUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
for(Account account : newAccounts)
{
// Get cached contacts (no SOQL query!)
List<Contact> contacts = TRG_CacheAccountContacts.getCachedContacts(account.Id);
// Update contact count
account.NumberOfContacts__c = contacts.size();
// Set primary contact name
if(!contacts.isEmpty())
{
Contact primaryContact = contacts[0]; // First contact
account.PrimaryContactName__c = primaryContact.FirstName + ' ' + primaryContact.LastName;
}
else
{
account.PrimaryContactName__c = null;
}
}
}
}Step 3: Configure Metadata with Correct Order
TriggerAction__mdt for Caching Action:
Label: Cache Account Contacts
Developer Name: Account_CacheContacts
Apex Class Name: TRG_CacheAccountContacts
Before Update: Account_Trigger_Setting
Order: 10
Description: Queries and caches Contact data for use by later actionsTriggerAction__mdt for Validation Action:
Label: Validate Premium Account Contacts
Developer Name: Account_ValidatePremiumContacts
Apex Class Name: TRG_ValidatePremiumAccountContacts
Before Update: Account_Trigger_Setting
Order: 20
Description: Validates premium accounts have at least one contact using cached dataTriggerAction__mdt for Summary Action:
Label: Update Account Contact Summary
Developer Name: Account_UpdateContactSummary
Apex Class Name: TRG_UpdateAccountContactSummary
Before Update: Account_Trigger_Setting
Order: 30
Description: Updates NumberOfContacts__c and PrimaryContactName__c using cached dataExecution Flow
Trigger fires on Account update
|
TRG_Dispatcher.run()
|
1. Order 10: TRG_CacheAccountContacts.beforeUpdate()
+-- Queries Contacts, populates contactsByAccountId cache
|
2. Order 20: TRG_ValidatePremiumAccountContacts.beforeUpdate()
+-- Calls TRG_CacheAccountContacts.getCachedContacts()
+-- Uses cached data (no SOQL!)
|
3. Order 30: TRG_UpdateAccountContactSummary.beforeUpdate()
+-- Calls TRG_CacheAccountContacts.getCachedContacts()
+-- Uses cached data (no SOQL!)Best Practices for Caching
- Use Static Variables: Cache data in
private staticvariables - Simple Values: For one-off values without a holder class, use the framework's transaction-scoped store
UTIL_Cache.inTransaction(); keep the typed caching action for structured record data - Order Matters: Caching action must have lower Order__c
- Provide Getters: Expose cached data via public static methods
- Handle Nulls: Return empty collections if cache not populated
- Clear in Tests: Provide
@TestVisibleclear methods for tests - Document Dependencies: Note in ApexDoc which actions depend on cache
- Same Context: Caching only works within same trigger context
Testing Cached Actions
/**
* @description Tests TRG_ValidatePremiumAccountContacts uses cache correctly
*
* @see TRG_ValidatePremiumAccountContacts
*
* @author <your-author-tag>
*
* @date January 2025
*/
@IsTest(SeeAllData=false IsParallel=true)
private class TRG_ValidatePremiumAccountContacts_TEST
{
/**
* @description Tests validation fails when no contacts and uses cache
*/
@IsTest
private static void beforeUpdate_givenPremiumWithNoContacts_shouldError()
{
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'Type' => 'Standard'
})
.build();
Test.startTest();
testAccount.Type = 'Premium';
Database.SaveResult result = Database.update(testAccount, false);
Test.stopTest();
Assert.isFalse(result.isSuccess(), 'Should fail validation');
Assert.isTrue(result.getErrors()[0].getMessage().contains('contact'), 'Error should mention contacts');
}
/**
* @description Tests validation passes when contacts exist
*/
@IsTest
private static void beforeUpdate_givenPremiumWithContacts_shouldSucceed()
{
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'Type' => 'Standard'
})
.build();
Contact testContact = (Contact)TST_Builder.of(Contact.SObjectType)
.withOverrides(new Map<String, Object>
{
'AccountId' => testAccount.Id,
'LastName' => 'Test Contact'
})
.build();
Test.startTest();
testAccount.Type = 'Premium';
DML_Builder.newTransaction().doUpdate(testAccount).execute();
Test.stopTest();
testAccount = (Account)QRY_Builder.selectFrom(Account.SObjectType)
.addField(Account.Type)
.condition(Account.Id).equals(testAccount.Id)
.getFirst();
Assert.areEqual('Premium', testAccount.Type, 'Should update to Premium successfully');
}
}How do I link actions together?
Because each action is small and runs in a known order, you can build complex behaviour by composing several simple actions rather than writing one large one. Think of the actions as functions in a pipeline, where:
- Each action has a single, focused responsibility
- Actions execute in a defined order (like function calls)
- Actions can be composed to create complex behaviour
- Data flows through actions via shared caches or DML results
Coordination Patterns
Pattern 1: Sequential Processing
Use Case: Multiple validations that must all pass.
Configuration:
Order 10: TRG_ValidateRequiredFields
Order 20: TRG_ValidateBusinessRules
Order 30: TRG_ValidateRelatedRecordsEffect: Each validation runs in sequence. If Order 10 adds an error, Orders 20 and 30 still execute (all errors are collected).
Pattern 2: Data Preparation - Processing
Use Case: Load data, then use it for multiple operations.
Configuration:
Order 10: TRG_CacheRelatedData (queries and caches)
Order 20: TRG_ValidateUsingCache (uses cache)
Order 30: TRG_PopulateFieldsUsingCache (uses cache)
Order 40: TRG_CreateRelatedRecords (uses cache)Effect: Single SOQL query in Order 10, reused by all later actions.
Pattern 3: Conditional Execution via Entry Criteria
Use Case: Different actions for different record types.
Configuration:
Order 10: TRG_ProcessPremiumAccounts
Entry Criteria: isPremiumTier
Order 20: TRG_ProcessStandardAccounts
Entry Criteria: NOT(isPremiumTier)Effect: Actions only receive records matching their criteria.
Pattern 4: Progressive Enhancement
Use Case: Build up record data through multiple stages.
Configuration:
Order 10: TRG_SetDefaultValues (populate defaults)
Order 20: TRG_CalculateDerivedFields (calculate based on defaults)
Order 30: TRG_EnrichFromExternalData (API callout if needed)
Order 40: TRG_FinalizeRecord (final validation)Effect: Each action builds on previous actions' work.
Pattern 5: Functional Composition
Use Case: Reuse actions across objects with different orderings.
Account Configuration:
Order 10: TRG_SetExternalReference
Order 20: TRG_ValidateEmailFormat
Order 30: TRG_NotifyOwnerContact Configuration:
Order 10: TRG_SetExternalReference (same action!)
Order 20: TRG_ValidateEmailFormat (same action!)
Order 30: TRG_UpdateAccountSummary (different action)Effect: Actions are like pure functions that work on any object implementing required fields.
Real-World Coordination Example
Scenario: Complex Case Response workflow with multiple validations and actions.
Trigger Actions:
TRG_CacheQuestionData (Order 10)
- Queries parent Question Cases
- Caches question status, reviewers, response count
- Shares cache via static getter
TRG_ValidateResponseStatus (Order 20)
- Uses cached question data
- Validates response status transitions
- Ensures question is not closed
TRG_PreventSameOwnerReviewer (Order 30)
- Uses cached question data
- Validates owner != reviewer
- Checks sibling responses for conflicts
TRG_UpdateQuestionProgress (Order 40)
- Uses cached question data
- Calculates completion percentage
- Updates parent Question
TRG_NotifyReviewers (Order 50)
- Uses cached question data
- Sends email to reviewers
- Creates tasks
Metadata Configuration:
All actions:
Before Update: Case_Trigger_Setting
Allow Recursion: false (prevent infinite loops)
TRG_CacheQuestionData:
Order: 10
TRG_ValidateResponseStatus:
Order: 20
Entry Criteria: isResponseRecordType
TRG_PreventSameOwnerReviewer:
Order: 30
Entry Criteria: isResponseRecordType && (ownerChanged || reviewerChanged)
TRG_UpdateQuestionProgress:
Order: 40
Entry Criteria: isResponseRecordType && statusChanged
TRG_NotifyReviewers:
Order: 50
Entry Criteria: isResponseRecordType && statusChanged && status == 'Submitted'Benefits:
- Single SOQL query (Order 10)
- Each action has single responsibility
- Actions can be tested independently
- Entry criteria filters records per action
- Easy to add/remove/reorder actions via metadata
What else can it do?
Recursion Prevention
An action that updates the same object can trigger itself, which risks an infinite loop. You can stop that by telling the framework to skip an action while an earlier run of it is still in progress. The action still runs for separate, later operations in the same transaction.
How It Works:
- TRG_Dispatcher maintains a stack of executing actions
- Before executing, checks if action is already in stack
- If
AllowRecursion__c = falseand action in stack, skips execution
Configuration:
TriggerAction__mdt:
Allow Recursion: falseWhen to Use:
- Action performs DML that might retrigger itself
- Action updates fields that trigger the same action
- Want the framework to stop that re-entry instead of writing your own guard flag
Example:
public inherited sharing class TRG_UpdateAccountFromContact extends TRG_Base implements IF_Trigger.AfterUpdate
{
public void afterUpdate(List<Contact> newContacts, List<Contact> oldContacts)
{
Set<Id> accountIds = new Set<Id>();
for(Contact contact : newContacts)
{
accountIds.add(contact.AccountId);
}
List<Account> accountsToUpdate = QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.LastContactUpdateDate__c})
.condition(Account.Id).isIn(accountIds)
.toList();
for(Account account : accountsToUpdate)
{
account.LastContactUpdateDate__c = System.now();
}
// This update might trigger Account trigger
// If AllowRecursion = false, won't cause issues
DML_Builder.newTransaction().doUpdate(accountsToUpdate).execute();
}
}Self-Initiated Control
Sometimes you want an action to run only when a user changes the object directly, not when another object's trigger changes it as a side effect. This setting gives you that control.
How It Works:
- TRG_Dispatcher tracks the first action in the stack
- If
AllowNonSelfInitiated__c = false, action only runs if it's first in stack - Prevents cascading trigger execution
Configuration:
TriggerAction__mdt:
Allow Non Self-Initiated: falseWhen to Use:
- Action should only run for direct DML on the object
- Don't want action to fire when related triggers create records
- Need strict control over execution context
Example:
// Contact action configured with AllowNonSelfInitiated = false
// Scenario 1: Direct Contact insert
DML_Builder.newTransaction().doInsert(new Contact(LastName = 'Test')).execute();
// Contact trigger fires
// TRG_ContactAction executes (self-initiated)
// Scenario 2: Account trigger creates Contact
trigger TRG_Account on Account (after insert)
{
new TRG_Dispatcher().run();
}
public inherited sharing class TRG_CreateDefaultContact ...
{
public void afterInsert(List<Account> newAccounts)
{
DML_Builder.newTransaction().doInsert(new Contact(AccountId = newAccounts[0].Id, LastName = 'Default')).execute();
// Contact trigger fires
// TRG_ContactAction does NOT execute (non-self-initiated)
}
}Bypass Mechanisms
During a data load, migration, or incident, you sometimes need to turn trigger logic off without a deployment. The framework gives you several levels of bypass: an entire object, a single action, or a flag-controlled group. Every programmatic bypass call (object-level and action-level) emits a LogEntryEvent__e at WARN level with a BypassEvent category, creating an audit trail of who bypassed which actions and why.
Object-Level Bypass (TriggerSetting__mdt)
Via BypassExecution__c:
TriggerSetting__mdt:
Object API Name: Account
Bypass Execution: trueEffect: ALL actions for Account are bypassed.
Programmatic Bypass:
// Bypass all actions for Account (type-safe — recommended)
TRG_Base.bypass(Account.SObjectType);
// Or bypass using string-based API
TRG_Base.bypass('Account');
// Perform DML - no actions execute
DML_Builder.newTransaction().doInsert(new Account(Name = 'Test')).execute();
// Clear bypass
TRG_Base.clearBypass(Account.SObjectType);Action-Level Bypass (TriggerAction__mdt)
Via BypassExecution__c:
TriggerAction__mdt:
Apex Class Name: TRG_ValidateAccount
Bypass Execution: trueEffect: Only TRG_ValidateAccount is bypassed.
Programmatic Bypass:
// Bypass specific action
TRG_Base.bypassAction('TRG_ValidateAccount');
// Perform DML - only this action is bypassed
DML_Builder.newTransaction().doInsert(new Account(Name = 'Test')).execute();
// Clear bypass
TRG_Base.clearActionBypass('TRG_ValidateAccount');
// Clear all action bypasses
TRG_Base.clearAllActionBypasses();Match the registered class name (casing is forgiven).
bypassActionmatches the string against the registered class name case-insensitively, but does not otherwise validate it: a misspelt or wrongly-namespaced name compiles and still records a bypass audit entry while the real action keeps running. When that happens, the audit entry carries anunmatchedTargetflag in its context, so an ineffective bypass shows up in the audit trail instead of passing silently. When bypassing your own action classes, prefer the compiler-checked formTRG_Base.bypassAction(TRG_ValidateAccount.class.getName())over a string literal.
Flow actions share one bypass key. Every flow action dispatches through the framework's built-in runner class, so the action-level key is that runner's name:
kern.TRG_InvokeFlowwhen you installed the managed package, orTRG_InvokeFlowin an unmanaged deploy. Bypassing it switches off every flow action for the rest of the transaction; to switch off a single flow row, set itsBypassExecution__cor gate it with a feature flag instead.
Bypass Audit Trail
Every call to TRG_Base.bypass*() / clearBypass*() / clearAll*Bypasses() emits a WARN-level LogEntryEvent__e tagged with category = 'BypassEvent' via UTIL_BypassAudit.emit. The event captures the running user, the action (BYPASS, CLEAR, CLEAR_ALL), the surface (trigger-object / trigger-action), the target (SObject API name or class name), and the optional reason set via TRG_Base.setBypassReason(String). When an action-level bypass or clear names a class that matches no registered trigger action, the entry also carries unmatchedTarget: true in its context: the bypass was recorded, but it can never take effect.
The same audit channel covers query (QRY_Builder.withSystemMode / bypassSharing / withoutSecurity → surface = 'query'), DML (DML_Builder.withSystemMode / bypassSharing → surface = 'dml'), and validation (UTIL_ValidationRule.bypassObject / bypassGroup / bypassRule → surface = 'validation') bypasses. In a noisy environment you can turn this runtime emission off via a FeatureFlagStrategy__mdt override on the BypassAudit_Enabled FeatureFlag__mdt record (it is on by default).
// Attach a business reason to any bypasses emitted in this transaction
TRG_Base.setBypassReason('Nightly data-integrity reconciliation batch');
TRG_Base.bypass(Account.SObjectType);
TRG_Base.bypassAction('TRG_ValidateAccount');The TRG_Base.BypassAction enum is global, so you can reason about bypass events directly in your own code. To query the audit log:
List<LogEntry__c> bypassEvents = QRY_Builder.selectFrom(LogEntry__c.SObjectType)
.addFields(new List<String>{'ShortMessage__c', 'ContextData__c', 'CreatedById', 'CreatedDate'})
.condition('ContextData__c').contains('"category":"BypassEvent"')
.orderBy('CreatedDate').descending()
.withLimit(100)
.toList();Why this matters: bypasses weaken security and automation guarantees. The audit trail answers "which user bypassed which action, when, and why?" without requiring every bypass call-site to remember to log the event.
Flow-Based Bypass (FLOW_BypassTrigger)
Declarative orgs can toggle bypasses from Flow without Apex. The FLOW_BypassTrigger invocable accepts a DTO_Request with action (BYPASS / CLEAR / CLEAR_ALL), bypassType (OBJECT_NAME / CLASS_NAME), and name (the SObject API name or action class name). The companion FLOW_CheckTriggerBypassed invocable reports whether a bypass is currently in effect. Flow Builder offers the action and bypassType values as picklists in the action's property panel (for both invocables), so you pick them rather than typing the raw values. Both emit the same BypassEvent-category audit log as the Apex API, so Flow-driven bypasses show up in the same audit trail.
Feature Flag Bypass
Via BypassFeatureFlag__c:
The BypassFeatureFlag__c field on TriggerSetting__mdt and TriggerAction__mdt is a MetadataRelationship lookup to FeatureFlag__mdt. When the referenced Feature Flag evaluates to true for the running user, the associated trigger actions are bypassed.
Create Feature Flag:
- Create a
FeatureFlag__mdtrecord with DeveloperNameBypass_Account_Triggers - Optionally add targeting strategies via
FeatureFlagStrategy__mdt
- Create a
Configure Metadata:
TriggerSetting__mdt:
Bypass Feature Flag: Bypass_Account_Triggers- Target Users:
- Use
FeatureFlagStrategy__mdtto target by Profile, Permission Set or Group, or custom logic - Or set
IsEnabledByDefault__c = trueto bypass for all users
- Use
Use Cases:
- Emergency kill switches, a master off-switch you can flip in an incident (enable the flag to bypass, no deployment required)
- Targeted bypass for integration users via a permission set or permission set group strategy
- Data migration bypass controlled by Feature Flag
Tip: For permission-based bypass, create a Feature Flag with a
Custom Permissionstrategy type inFeatureFlagStrategy__mdt.
Feature Flag Gating
Via RequiredFeatureFlag__c:
The RequiredFeatureFlag__c field on TriggerSetting__mdt and TriggerAction__mdt is a MetadataRelationship lookup to FeatureFlag__mdt. Actions only execute when the referenced Feature Flag evaluates to true for the running user.
Create Feature Flag:
- Create a
FeatureFlag__mdtrecord with DeveloperNameEnable_Account_Validation - Add targeting strategies via
FeatureFlagStrategy__mdt
- Create a
Configure Metadata:
TriggerAction__mdt:
Required Feature Flag: Enable_Account_Validation- Target Users:
- Use
FeatureFlagStrategy__mdtto control which users get the feature
- Use
Use Cases:
- Gradual rollout of new trigger logic to targeted user groups
- Premium features only for specific profiles
- Controlled enablement of new validation rules
Feature Flag Integration
Feature flags let you roll an action out to a chosen group of users, or switch it off in an emergency, without redeploying. The BypassFeatureFlag__c and RequiredFeatureFlag__c fields on both TriggerSetting__mdt and TriggerAction__mdt are MetadataRelationship lookups to FeatureFlag__mdt records managed through the Feature Flag framework. They appear as dropdown pickers in the Custom Metadata UI, which keeps the link valid and prevents typos.
How It Works:
1. If no Feature Flag is selected -> Condition not met (no bypass/no requirement)
2. Resolve the referenced FeatureFlag__mdt record
3. Evaluate the Feature Flag for the running user (strategies, defaults, active state)
4. If Feature Flag is enabled -> Condition is met (bypass applies / requirement satisfied)Custom Permissions are not referenced directly in these fields. To use permission-based targeting, create a Feature Flag with a Custom Permission strategy type in FeatureFlagStrategy__mdt.
Example 1: Gradual Rollout with Feature Flag
// Step 1: Create Feature Flag for new validation
// FeatureFlag__mdt:
// DeveloperName: 'Enable_New_Account_Validation'
// IsActive__c: true
// IsEnabledByDefault__c: false (disabled for most users)
//
// FeatureFlagStrategy__mdt:
// Strategy: Permission Set Group
// TargetValue: 'Beta_Testers'
// IsEnabled: true
// Step 2: Configure TriggerAction__mdt
// RequiredFeatureFlag__c: 'Enable_New_Account_Validation'
// Result: Action ONLY runs for users in the Beta_Testers Permission Set Group
// When ready for GA: Set FeatureFlag IsEnabledByDefault__c = trueExample 2: Kill Switch with Feature Flag
// Step 1: Create Feature Flag for emergency bypass
// FeatureFlag__mdt:
// DeveloperName: 'Disable_Account_Sync'
// IsActive__c: true
// IsEnabledByDefault__c: false (sync normally runs)
// Step 2: Configure TriggerAction__mdt
// BypassFeatureFlag__c: 'Disable_Account_Sync'
// Normal operation: Flag is disabled -> Sync action runs
// Emergency: Set IsEnabledByDefault__c = true -> Sync action is BYPASSED
// No deployment required!Example 3: Profile-Based Targeting
// Step 1: Create Feature Flag with Profile strategy
// FeatureFlag__mdt:
// DeveloperName: 'Enable_Premium_Validation'
// IsActive__c: true
//
// FeatureFlagStrategy__mdt:
// Strategy: Profile
// TargetValue: 'Premium_User_Profile'
// IsEnabled: true
// Step 2: Configure TriggerAction__mdt
// RequiredFeatureFlag__c: 'Enable_Premium_Validation'
// Result: Action only runs for users with Premium_User_ProfileLogging:
When an action is skipped due to bypass or missing required condition, an INFO log is written:
Trigger action "Account_ValidateRevenue" on Account SKIPPED. Action bypass: Disable_Account_Validation
Trigger action "Account_SyncExternal" on Account SKIPPED. Setting required not met: Enable_External_SyncEntry Criteria Formulas
To run an action only for certain records (say, only Premium accounts), give it an entry criteria formula. The framework evaluates that formula per record and passes only the matching records to your action, so the action's own code stays free of filtering logic.
Simple Entry Criteria
Context Class:
/**
* @description Formula evaluation context for Account records. Provides old and new record states
* for formula-based filtering and evaluation in trigger and automation contexts.
*
* @see UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContext
*
* @author <your-author-tag>
*
* @date January 2025
*/
@SuppressWarnings('PMD.AvoidGlobalModifier')
global with sharing class UTIL_AccountContext implements UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContext
{
/**
* @description The Account record state before the DML operation
*/
global Account oldRecord;
/**
* @description The Account record state after the DML operation
*/
global Account newRecord;
/**
* @description Checks if the account is premium tier
*/
global Boolean isPremium
{
get { return newRecord?.Type == 'Premium'; }
}
/**
* @description Sets the context for formula evaluation with old and new Account records
*
* @param oldSObject The record state before the DML operation
* @param newSObject The record state after the DML operation
*/
public void setContext(SObject oldSObject, SObject newSObject)
{
oldRecord = (Account)oldSObject ?? new Account();
newRecord = (Account)newSObject ?? new Account();
}
}Formula:
isPremiumEffect: Action only receives Premium accounts.
Complex Entry Criteria
Context Class:
/**
* @description Formula evaluation context for Account records with complex business logic.
* Provides old and new record states for formula-based filtering and evaluation.
*
* @see UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContext
*
* @author <your-author-tag>
*
* @date January 2025
*/
@SuppressWarnings('PMD.AvoidGlobalModifier')
global with sharing class UTIL_AccountContext implements UTIL_FormulaFilter.INT_SObjectFormulaEvaluationContext
{
/**
* @description The Account record state before the DML operation
*/
global Account oldRecord;
/**
* @description The Account record state after the DML operation
*/
global Account newRecord;
/**
* @description Checks if the account is premium tier
*/
global Boolean isPremium
{
get { return newRecord?.Type == 'Premium'; }
}
/**
* @description Checks if the status field changed
*/
global Boolean statusChanged
{
get { return oldRecord != null && newRecord?.Status__c != oldRecord.Status__c; }
}
/**
* @description Checks if the account is active
*/
global Boolean isActive
{
get { return newRecord?.Status__c == 'Active'; }
}
/**
* @description Checks if the account has high annual revenue
*/
global Boolean isHighValue
{
get { return newRecord?.AnnualRevenue != null && newRecord.AnnualRevenue > 1000000; }
}
/**
* @description Sets the context for formula evaluation with old and new Account records
*
* @param oldSObject The record state before the DML operation
* @param newSObject The record state after the DML operation
*/
public void setContext(SObject oldSObject, SObject newSObject)
{
oldRecord = (Account)oldSObject ?? new Account();
newRecord = (Account)newSObject ?? new Account();
}
}Formula Examples:
isPremium && statusChanged
isPremium && isActive
isHighValue && (statusChanged || oldRecord == null)
(isPremium || isHighValue) && isActiveEffect: Framework evaluates formula for each record and only passes matching records to action.
Common Patterns
Most trigger work falls into a handful of recurring shapes. Each pattern below pairs a common job with a worked action you can copy and adapt.
Validation Pattern
Use Case: Validate record data before save.
Implementation:
/**
* @description Validates Account fields before insert/update.
*
* @author <your-author-tag>
*
* @date January 2025
*/
public inherited sharing class TRG_ValidateAccount extends TRG_Base implements
IF_Trigger.BeforeInsert,
IF_Trigger.BeforeUpdate
{
public void beforeInsert(List<Account> newAccounts)
{
validate(newAccounts);
}
public void beforeUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
validate(newAccounts);
}
private static void validate(List<Account> accounts)
{
for(Account account : accounts)
{
// Validate: Annual Revenue must be positive
if(account.AnnualRevenue != null && account.AnnualRevenue < 0)
{
account.AnnualRevenue.addError('Annual Revenue cannot be negative');
}
// Validate: Premium accounts need phone
if(account.Type == 'Premium' && String.isBlank(account.Phone))
{
account.Phone.addError('Phone is required for Premium accounts');
}
// Validate: Email format
if(String.isNotBlank(account.Email__c) && !account.Email__c.contains('@'))
{
account.Email__c.addError('Invalid email format');
}
}
}
}Field Population Pattern
Use Case: Auto-populate fields based on business rules.
Implementation:
/**
* @description Populates calculated and default fields on Account.
*
* @author <your-author-tag>
*
* @date January 2025
*/
public inherited sharing class TRG_PopulateAccountFields extends TRG_Base implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<Account> newAccounts)
{
for(Account account : newAccounts)
{
// Set default industry
if(String.isBlank(account.Industry))
{
account.Industry = 'Other';
}
// Generate account number
if(String.isBlank(account.Description))
{
account.Description = 'ACC-' + String.valueOf(System.now().getTime());
}
// Set tier based on revenue
if(account.AnnualRevenue != null)
{
if(account.AnnualRevenue > 10000000)
{
account.Tier__c = 'Platinum';
}
else if(account.AnnualRevenue > 1000000)
{
account.Tier__c = 'Gold';
}
else
{
account.Tier__c = 'Silver';
}
}
}
}
}Related Record Creation Pattern
Use Case: Create related records after parent insert/update.
Implementation:
/**
* @description Creates default Contact for new Accounts.
*
* @author <your-author-tag>
*
* @date January 2025
*/
public inherited sharing class TRG_CreateDefaultContact extends TRG_Base implements IF_Trigger.AfterInsert
{
public void afterInsert(List<Account> newAccounts)
{
List<Contact> contactsToInsert = new List<Contact>();
for(Account account : newAccounts)
{
contactsToInsert.add(new Contact(
AccountId = account.Id,
LastName = account.Name + ' Contact',
Email = account.Email__c,
Phone = account.Phone
));
}
if(!contactsToInsert.isEmpty())
{
DML_Builder.newTransaction().doInsert(contactsToInsert).execute();
}
}
}Cascade Update Pattern
Use Case: Update related records when parent changes.
Implementation:
/**
* @description Cascades Account phone updates to related Contacts.
*
* @author <your-author-tag>
*
* @date January 2025
*/
public inherited sharing class TRG_CascadePhoneToContacts extends TRG_Base implements IF_Trigger.AfterUpdate
{
public void afterUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
Map<Id, String> accountIdToNewPhone = new Map<Id, String>();
// Identify phone changes
for(Integer i = 0; i < newAccounts.size(); i++)
{
Account newAccount = newAccounts.get(i);
Account oldAccount = oldAccounts.get(i);
if(newAccount.Phone != oldAccount.Phone)
{
accountIdToNewPhone.put(newAccount.Id, newAccount.Phone);
}
}
if(!accountIdToNewPhone.isEmpty())
{
// Query contacts using QRY_Builder
List<Contact> contactsToUpdate = QRY_Builder.selectFrom(Contact.SObjectType)
.addFields(new List<SObjectField>{Contact.Id, Contact.AccountId, Contact.Phone})
.condition(Contact.AccountId).isIn(accountIdToNewPhone.keySet())
.toList();
// Update phones
for(Contact contact : contactsToUpdate)
{
contact.Phone = accountIdToNewPhone.get(contact.AccountId);
}
if(!contactsToUpdate.isEmpty())
{
DML_Builder.newTransaction().doUpdate(contactsToUpdate).execute();
}
}
}
}Audit Trail Pattern
Use Case: Log field changes for compliance.
Implementation:
/**
* @description Logs Account status changes to audit trail.
*
* @author <your-author-tag>
*
* @date January 2025
*/
public inherited sharing class TRG_AuditStatusChanges extends TRG_Base implements IF_Trigger.AfterUpdate
{
public void afterUpdate(List<Account> newAccounts, List<Account> oldAccounts)
{
List<AuditLog__c> logsToInsert = new List<AuditLog__c>();
for(Integer i = 0; i < newAccounts.size(); i++)
{
Account newAccount = newAccounts.get(i);
Account oldAccount = oldAccounts.get(i);
if(newAccount.Status__c != oldAccount.Status__c)
{
logsToInsert.add(new AuditLog__c(
RecordId__c = newAccount.Id,
ObjectType__c = 'Account',
FieldName__c = 'Status__c',
OldValue__c = oldAccount.Status__c,
NewValue__c = newAccount.Status__c,
ChangedBy__c = UserInfo.getUserId(),
ChangedDate__c = System.now()
));
}
}
if(!logsToInsert.isEmpty())
{
DML_Builder.newTransaction().doInsert(logsToInsert).execute();
}
}
}Testing
Because each action is a small, self-contained class, you can test it on its own rather than exercising the whole trigger. The examples below show the two common styles: a full save through the framework, and a direct call to one action with no database writes at all.
Testing Individual Actions
/**
* @description Test class for TRG_ValidateAccount
*
* @see TRG_ValidateAccount
*
* @author <your-author-tag>
*
* @date January 2025
*/
@IsTest(SeeAllData=false IsParallel=true)
private class TRG_ValidateAccount_TEST
{
/**
* @description Tests that negative revenue fails validation
*/
@IsTest
private static void beforeInsert_givenNegativeRevenue_shouldError()
{
Test.startTest();
Database.SaveResult result = Database.insert(
(Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'AnnualRevenue' => -1000
})
.withoutInsertion()
.build(),
false
);
Test.stopTest();
Assert.isFalse(result.isSuccess(), 'Should fail validation');
Assert.isTrue(result.getErrors()[0].getMessage().contains('negative'), 'Error should mention negative');
}
/**
* @description Tests that premium without phone fails
*/
@IsTest
private static void beforeInsert_givenPremiumWithoutPhone_shouldError()
{
Test.startTest();
Database.SaveResult result = Database.insert(
(Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'Type' => 'Premium',
'Phone' => null
})
.withoutInsertion()
.build(),
false
);
Test.stopTest();
Assert.isFalse(result.isSuccess(), 'Should fail validation');
Assert.isTrue(result.getErrors()[0].getMessage().contains('Phone'), 'Error should mention phone');
}
/**
* @description Tests valid account passes
*/
@IsTest
private static void beforeInsert_givenValidAccount_shouldSucceed()
{
Test.startTest();
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'Type' => 'Premium',
'Phone' => '555-1234',
'AnnualRevenue' => 1000000
})
.build();
Test.stopTest();
Assert.isNotNull(testAccount.Id, 'Account should be created');
}
}Testing an Action Without DML
Every action receives its records as a plain List<SObject>, so you can unit-test one in isolation with no insert, no dispatcher, and no TriggerAction__mdt deployment. Build the trigger records in memory, and if the action reads related data through a selector, register those rows with TST_Mock so the query returns them without a database round-trip.
@IsTest(SeeAllData=false IsParallel=true)
private class TRG_FlagHighValueAccount_TEST
{
@IsTest
private static void beforeUpdate_givenRevenueJump_flagsAccount()
{
// 1. Trigger rows, in memory — valid mock Ids, no DML.
Account newRecord = (Account)TST_Mock.of(Account.SObjectType)
.withOverride(Account.Name, 'Acme')
.withOverride(Account.AnnualRevenue, 5000000)
.build();
Account oldRecord = (Account)TST_Builder.of(Account.SObjectType)
.withOverride(Account.Id, newRecord.Id)
.withOverride(Account.AnnualRevenue, 100000)
.withoutInsertion()
.build();
// 2. The action reads Contacts through a selector — mock that read, still no DML.
TST_Mock.register(Contact.SObjectType, new List<Contact>
{
(Contact)TST_Builder.of(Contact.SObjectType)
.withOverride(Contact.AccountId, newRecord.Id)
.withoutInsertion()
.build()
});
// 3. Invoke the action method directly and assert.
Test.startTest();
new TRG_FlagHighValueAccount().beforeUpdate(
new List<SObject>{ newRecord },
new List<SObject>{ oldRecord }
);
Test.stopTest();
Assert.isTrue(newRecord.Priority_Review__c, 'A large revenue jump should flag the account');
TST_Mock.clear();
}
}TST_Mock.of(...).build() creates a record with a valid mock Id and auto-registers it; TST_Builder.of(...).withoutInsertion() builds an in-memory record without touching the database (give it an Id with withOverride(<Object>.Id, ...) or withoutInsertion(true) when the code under test needs one). TST_Mock.register(...) then makes the action's selector queries return the records you supply. The test exercises one rule's logic with no DML and no setup beyond the records it cares about.
Testing with Bypass
/**
* @description Tests bypass functionality
*/
@IsTest
private static void bypass_givenBypassed_shouldSkipAction()
{
TRG_Base.bypassAction('TRG_ValidateAccount');
Test.startTest();
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'AnnualRevenue' => -1000 // Invalid!
})
.build();
Test.stopTest();
// Should succeed (validation was bypassed)
Assert.isNotNull(testAccount.Id, 'Account should be created despite invalid data');
TRG_Base.clearAllActionBypasses();
}Testing Action Coordination
/**
* @description Tests that caching action provides data to validation action
*/
@IsTest
private static void coordination_givenCacheAndValidation_shouldWork()
{
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'Type' => 'Standard'
})
.build();
Test.startTest();
testAccount.Type = 'Premium';
Database.SaveResult result = Database.update(testAccount, false);
Test.stopTest();
// Should fail (validation used cached data showing no contacts)
Assert.isFalse(result.isSuccess(), 'Should fail validation');
}Testing Multiple Actions in Order
/**
* @description Tests that actions execute in correct order
*/
@IsTest
private static void ordering_givenMultipleActions_shouldExecuteInOrder()
{
Test.startTest();
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'ExternalReference__c' => null, // Order 10 populates
'Industry' => null, // Order 20 populates
'AccountNumber' => null // Order 30 populates
})
.build();
Test.stopTest();
testAccount = (Account)QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.ExternalReference__c, Account.Industry, Account.AccountNumber})
.condition(Account.Id).equals(testAccount.Id)
.getFirst();
Assert.isNotNull(testAccount.ExternalReference__c, 'Order 10 should populate');
Assert.areEqual('Other', testAccount.Industry, 'Order 20 should populate');
Assert.isNotNull(testAccount.AccountNumber, 'Order 30 should populate');
}Testing Entry Criteria
/**
* @description Tests entry criteria filters records
*/
@IsTest
private static void entryCriteria_givenNonPremium_shouldNotExecute()
{
Test.startTest();
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<String, Object>
{
'Name' => 'Test Account',
'Type' => 'Standard',
'PremiumValidation__c' => 'NotRun' // Action would set this
})
.build();
Test.stopTest();
testAccount = (Account)QRY_Builder.selectFrom(Account.SObjectType)
.addFields(new List<SObjectField>{Account.Id, Account.PremiumValidation__c})
.condition(Account.Id).equals(testAccount.Id)
.getFirst();
Assert.areEqual('NotRun', testAccount.PremiumValidation__c, 'Action should not run for non-premium');
}Performance Logging
When a trigger gets slow, you need to know which action is to blame without scattering timing code through your classes. The framework times every action for you and logs the slow ones, so you can find the bottleneck from a report instead of by guesswork.
Performance Logging Overview
When enabled, the framework:
- Times each trigger action's execution
- Compares duration against configurable thresholds
- Logs entries to
LogEntry__cwhen thresholds are exceeded - Captures rich context (action name, trigger operation, object, record count)
Performance Log Entry Example:
LogEntry__c:
ShortMessage__c: "Trigger Performance: TRG_AccountValidateName"
Message__c: "Action: TRG_AccountValidateName | Operation: BEFORE_INSERT | Object: Account | Records: 200 | Duration: 1523ms"
LogLevel__c: "INFO"
ContextData__c: {"operationType":"TRIGGER_ACTION","actionName":"TRG_AccountValidateName","triggerOperation":"BEFORE_INSERT","objectName":"Account","recordCount":200,...}Configuration Hierarchy
Performance logging uses a 3-tier configuration hierarchy (highest priority first):
+-------------------------------------------------------------+
| 1. TriggerAction__mdt (Action-Level) - Highest Priority |
| ForcePerformanceLogging__c = true -> Always log |
| SuppressPerformanceLogging__c = true -> Never log |
| PerformanceThresholdMs__c = 100 -> Custom threshold |
+-------------------------------------------------------------+
| 2. TriggerSetting__mdt (Object-Level) |
| EnablePerformanceLogging__c = true -> Enable for object |
| PerformanceThresholdMs__c = 500 -> Object threshold |
+-------------------------------------------------------------+
| 3. LogSetting__c (Global) |
| EnableTriggerPerformanceLogging__c = true -> Enable all |
| TriggerPerformanceThresholdMs__c = 500 -> Global default |
+-------------------------------------------------------------+Configuration Fields:
| Level | Metadata Type | Field | Description |
|---|---|---|---|
| Action | TriggerAction__mdt | ForcePerformanceLogging__c | Always log this action (ignores threshold) |
| Action | TriggerAction__mdt | SuppressPerformanceLogging__c | Never log this action |
| Action | TriggerAction__mdt | PerformanceThresholdMs__c | Custom threshold for this action (ms) |
| Object | TriggerSetting__mdt | EnablePerformanceLogging__c | Enable logging for this object's triggers |
| Object | TriggerSetting__mdt | PerformanceThresholdMs__c | Threshold for this object (ms) |
| Global | LogSetting__c | EnableTriggerPerformanceLogging__c | Enable trigger performance logging |
| Global | LogSetting__c | TriggerPerformanceThresholdMs__c | Default threshold (ms) |
Typical Configuration:
LogSetting__c (Org Default):
EnableTriggerPerformanceLogging__c: true
TriggerPerformanceThresholdMs__c: 500 <- Log actions taking > 500ms
TriggerSetting__mdt (Account_Trigger_Setting):
EnablePerformanceLogging__c: true
PerformanceThresholdMs__c: 200 <- Account triggers: stricter 200ms threshold
TriggerAction__mdt (TRG_AccountExternalSync):
ForcePerformanceLogging__c: true <- Always log this slow action
PerformanceThresholdMs__c: null <- Uses object threshold (200ms)
TriggerAction__mdt (TRG_AccountSetDefaults):
SuppressPerformanceLogging__c: true <- Never log (known to be fast)How Performance Logging Works
The TRG_Dispatcher class uses UTIL_TriggerPerformanceTimer internally to time each action:
+----------------------------------------------------------------------+
| new TRG_Dispatcher().run() |
+----------------------------------------------------------------------+
| for each TriggerAction__mdt in execution order: |
| |
| 1. Check configuration hierarchy for logging settings |
| 2. Create UTIL_TriggerPerformanceTimer |
| 3. timer.start() |
| 4. Execute action (e.g., beforeInsert()) |
| 5. timer.stop() |
| -> If duration > threshold AND logging enabled: |
| -> Publish LogEntryEvent__e with performance data |
| |
+----------------------------------------------------------------------+What Gets Captured:
| Field | Description |
|---|---|
| Action Name | Fully qualified class name (e.g., TRG_AccountValidateName) |
| Trigger Operation | BEFORE_INSERT, AFTER_UPDATE, etc. |
| Object Name | SObject API name (e.g., Account) |
| Record Count | Number of records in Trigger.new or Trigger.old |
| Duration (ms) | Actual execution time in milliseconds |
| Governor Limits | CPU time, heap, SOQL count deltas (when available) |
Viewing Performance Logs
Query Performance Logs:
// Find slow trigger actions in the last 24 hours
List<LogEntry__c> slowTriggers = QRY_Builder.selectFrom(LogEntry__c.SObjectType)
.addFields(new List<String>{'ShortMessage__c', 'Message__c', 'DurationMs__c', 'ContextData__c', 'CreatedDate'})
.condition('ShortMessage__c').contains('Trigger Performance:')
.andCondition('CreatedDate').greaterThanOrEquals(System.now().addDays(-1))
.orderBy('DurationMs__c').descending()
.withLimit(50)
.toList();
for(LogEntry__c log : slowTriggers)
{
LOG_Builder.build().info(log.DurationMs__c + 'ms - ' + log.ShortMessage__c).emitAt('TriggerPerformance.review');
}Sample Report Filter:
Create a report on LogEntry__c with:
- Filter:
ShortMessage__ccontains "Trigger Performance" - Group by:
ShortMessage__c(action name) - Summarise:
AVG(DurationMs__c),MAX(DurationMs__c),COUNT(Id)
Best Practices:
| Practice | Rationale |
|---|---|
| Start with global threshold of 500ms | Catches significant issues without noise |
Use ForcePerformanceLogging__c for known slow actions | Monitor integrations, complex validations |
Use SuppressPerformanceLogging__c for trivial actions | Reduce log volume for simple field defaults |
| Review logs weekly | Identify degrading performance trends |
| Set stricter thresholds for high-volume objects | Catch issues before they impact users |
See Also: For the full logging documentation, including correlation tracking (one tracking ID that follows a single user action across triggers, queries, callouts, and jobs), the context stack, and other performance timers, see Logging - Guide.
Capability Matrix (for Analysts)
If you configure triggers without writing Apex, this table is your map: each row is a capability, the metadata record and field that control it, and what it does.
| Capability | Custom Metadata | Field | Notes |
|---|---|---|---|
| Object-level bypass | TriggerSetting__mdt | BypassExecution__c | Disables all trigger actions for an object |
| Feature Flag bypass | TriggerSetting__mdt | BypassFeatureFlag__c | Bypasses all actions when the specified Feature Flag is enabled |
| Feature Flag gating | TriggerSetting__mdt | RequiredFeatureFlag__c | Only executes when the specified Feature Flag is enabled |
| Performance logging | TriggerSetting__mdt | EnablePerformanceLogging__c | Logs execution timing per trigger action |
| Performance threshold | TriggerSetting__mdt | PerformanceThresholdMs__c | Only logs actions exceeding this millisecond threshold |
| Action-level bypass | TriggerAction__mdt | BypassExecution__c | Disables a single trigger action |
| Execution ordering | TriggerAction__mdt | Order__c | Controls execution sequence within an event |
| Recursion control | TriggerAction__mdt | AllowRecursion__c | Allows an action to run again while an earlier run of it is still in progress |
| Self-initiated control | TriggerAction__mdt | AllowNonSelfInitiated__c | Controls behaviour for framework-initiated DML |
| Entry criteria | TriggerAction__mdt | EntryCriteriaFormula__c | Formula-based conditional execution |
| Bypass audit trail | Platform event | LogEntryEvent__e tagged category=BypassEvent | Every programmatic bypass call across trigger / query / DML / validation surfaces emits a WARN log with user, action, surface, target, and optional reason; BypassAudit_Enabled flag is the kill-switch |
Anti-Patterns
These are the common mistakes that make trigger logic hard to maintain. Each row names the mistake, why it hurts, and what to do instead.
| Anti-Pattern | Why It's Wrong | Instead |
|---|---|---|
| Inline SOQL inside a trigger action | Duplicates queries, makes bulk processing fragile, and bypasses selector caching | Use SEL_* selectors or QRY_Builder; share data via caching actions |
| Multiple responsibilities in one action | Hard to test, hard to reorder, and violates single responsibility principle | Split into separate actions with distinct Order__c values |
Hardcoded bypass flags (static Boolean) | Not metadata-driven, invisible to admins, and cannot be toggled per-environment | Use BypassExecution__c on TriggerAction__mdt or BypassFeatureFlag__c |
| Business logic in the physical trigger file | Cannot be tested independently, cannot be reused, and makes the trigger monolithic | Move all logic into TRG_* action classes configured via metadata |
| Relying on trigger execution order without metadata | Execution order is unpredictable when multiple triggers exist on the same object | Use Order__c on TriggerAction__mdt to guarantee sequence |
Best Practices
One responsibility per action - Each trigger action class should handle a single concern (e.g., validation, field defaulting, related record creation). Avoid combining multiple unrelated operations in the same action class.
Use metadata ordering intentionally - Assign
Order__cvalues with gaps (10, 20, 30) to allow future actions to be inserted between existing ones without renumbering. Place validation actions before field population actions, and field population before related record creation.Prefer
inherited sharingon action classes - Useinherited sharingunless your action has a specific requirement forwith sharingorwithout sharing. This respects the calling context and avoids unexpected permission issues.Always set
AllowRecursion__cdeliberately - Default tofalsefor most actions to prevent infinite loops. Only enable recursion for actions that must re-execute when their own changes trigger the object again (e.g., cascade updates).Use entry criteria to reduce processing - Configure
EntryCriteriaFormula__conTriggerAction__mdtto skip actions when records do not meet conditions. This avoids unnecessary class instantiation and execution for irrelevant records.Use caching actions for shared queries - When multiple actions need the same related data, create a dedicated caching action with a low
Order__cvalue that queries once and stores results in a static variable. Subsequent actions read from the cache instead of re-querying.Do not bypass triggers in production without a plan - Use
BypassExecution__corBypassFeatureFlag__csparingly. Document why a bypass exists and who should have it. Prefer Feature Flag-based bypasses over metadata checkboxes so that only targeted users skip trigger logic.Test actions in isolation - Each trigger action should have its own test class that configures the required
TriggerSetting__mdtandTriggerAction__mdtviaTST_Factory, inserts records viaTST_Builder, and asserts the expected outcome.
Troubleshooting
Find your symptom below, then work through the likely causes and the fixes for each.
Common Issues
Issue: Action Not Executing
Symptoms: Code doesn't run, no errors.
Possible Causes:
BypassExecution__cis checked in metadata- Object-level
BypassExecution__cis checked BypassFeatureFlag__cis enabled for the userRequiredFeatureFlag__cis not enabled for the user- Entry criteria formula evaluates to false for all records
- Wrong trigger context configured
AllowRecursion__c = falseand action executing recursivelyAllowNonSelfInitiated__c = falseand triggered indirectly
Solutions:
- Check metadata configuration:
List<TriggerAction__mdt> actions = SEL_TriggerAction.findActiveByObjectNameAndTriggerOperation('Account', TriggerOperation.BEFORE_INSERT);
LOG_Builder.build().debug(String.valueOf(actions)).emitAt('TriggerDebug.checkMetadata');- Check bypass status:
Boolean isActionBypassed = TRG_Base.isActionBypassed('TRG_MyAction');
Boolean isObjectBypassed = TRG_Base.isBypassed('Account');
LOG_Builder.build().debug('Action bypassed: ' + isActionBypassed).emitAt('TriggerDebug.checkBypass');
LOG_Builder.build().debug('Object bypassed: ' + isObjectBypassed).emitAt('TriggerDebug.checkBypass');- Add debug logging in action:
public void beforeInsert(List<Account> newAccounts)
{
LOG_Builder.build().debug('beforeInsert - START').emitAt('TRG_MyAction.beforeInsert');
LOG_Builder.build().debug('Record count: ' + newAccounts.size()).emitAt('TRG_MyAction.beforeInsert');
// ... logic
LOG_Builder.build().debug('beforeInsert - END').emitAt('TRG_MyAction.beforeInsert');
}- Verify trigger exists and calls framework:
trigger TRG_Account on Account (before insert, before update)
{
LOG_Builder.build().debug('Trigger firing').emitAt('TRG_Account.trigger');
new TRG_Dispatcher().run();
}Issue: Wrong Execution Order
Symptoms: Actions run in unexpected sequence.
Possible Causes:
Order__cnot set correctly- Multiple actions with same
Order__c
Solutions:
- Query and verify order:
List<TriggerAction__mdt> actions = SEL_TriggerAction.findActiveByObjectNameAndTriggerOperation('Account', TriggerOperation.BEFORE_INSERT);
for(TriggerAction__mdt action : actions)
{
String target = String.isNotBlank(action.ApexClassName__c) ? action.ApexClassName__c : 'Flow:' + action.FlowName__c;
LOG_Builder.build().debug(action.Order__c + ': ' + target).emitAt('Troubleshooting.verifyOrder');
}- Update order to use increments of 10:
Order 10: TRG_CacheData
Order 20: TRG_Validate
Order 30: TRG_Populate
Order 40: TRG_CreateRelatedIssue: Entry Criteria Not Working
Symptoms: Action executes for all records or wrong records.
Possible Causes:
- Formula syntax error
- Context class properties don't match formula
- Context class not instantiable
Solutions:
- Test context class independently:
Account testAccount = (Account)TST_Builder.of(Account.SObjectType)
.withOverrides(new Map<SObjectField, Object>{ Account.Type => 'Premium', Account.Status__c => 'Active' })
.withoutInsertion()
.build();
UTIL_AccountContext context = new UTIL_AccountContext();
context.newRecord = testAccount;
Assert.isTrue(context.isPremium, 'Account Type "Premium" should set isPremium to true');
Assert.isTrue(context.isActive, 'Account Status "Active" should set isActive to true');Check formula syntax in metadata
Temporarily remove formula to verify action works without it
Issue: Governor Limits Exceeded
Symptoms: SOQL query limit, DML row limit, or CPU time exceeded.
Possible Causes:
- SOQL in loops
- Not using caching pattern
- Multiple actions querying same data
Solutions:
- Implement caching pattern:
// BAD - Each action queries separately
public class TRG_Action1 {
public void beforeUpdate(List<Account> accounts) {
List<Contact> contacts = [SELECT...]; // Query 1
}
}
public class TRG_Action2 {
public void beforeUpdate(List<Account> accounts) {
List<Contact> contacts = [SELECT...]; // Query 2 (duplicate!)
}
}
// GOOD - Cache in Order 10, reuse in later actions
public inherited sharing class TRG_CacheContacts extends TRG_Base
{
private static Map<Id, List<Contact>> cache;
public void beforeUpdate(List<Account> accounts)
{
// Query once, cache
}
public static List<Contact> getCached(Id accountId)
{
return cache.get(accountId);
}
}
public inherited sharing class TRG_Action1 extends TRG_Base
{
public void beforeUpdate(List<Account> accounts)
{
List<Contact> contacts = TRG_CacheContacts.getCached(account.Id); // No query!
}
}- Use bulkification:
// BAD - DML in loop
for(Account acc : accounts) {
insert new Task(WhatId = acc.Id);
}
// GOOD - Collect and DML once
List<Task> tasks = new List<Task>();
for(Account account : accounts)
{
tasks.add(new Task(WhatId = account.Id));
}
DML_Builder.newTransaction().doInsert(tasks).execute();Issue: Recursion Loop
Symptoms: Stack depth exceeded, governor limits.
Possible Causes:
AllowRecursion__c = true(default)- Action causes DML that triggers itself
Solutions:
Set
AllowRecursion__c = falsein metadataAdd static flag:
private static Boolean hasExecuted = false;
public void afterUpdate(List<Account> accounts)
{
if(hasExecuted) return;
hasExecuted = true;
// ... logic that might cause recursion
}Issue: Type Exception
Symptoms: "does not implement required interface"
Possible Causes:
- Class doesn't implement correct interface for context
- Typo in class name in metadata
Solutions:
- Verify class implements interface:
// For Before Insert context
public inherited sharing class TRG_MyAction extends TRG_Base implements IF_Trigger.BeforeInsert
{
public void beforeInsert(List<SObject> newRecords) { }
}- Verify class name in metadata matches exactly
Debugging Tips
Enable Debug Logs
- Setup > Debug Logs > New
- Select user
- Set Apex Code to FINEST
- Reproduce issue
Add Strategic Log Statements
public void beforeInsert(List<Account> newAccounts)
{
LOG_Builder.build().debug('START beforeInsert, record count: ' + newAccounts.size()).emitAt('TRG_MyAction.beforeInsert');
// ... logic
LOG_Builder.build().debug('END beforeInsert').emitAt('TRG_MyAction.beforeInsert');
}Test in Isolation
@IsTest
private static void isolatedTest()
{
// Bypass all other actions
TRG_Base.bypassAction('TRG_Action1');
TRG_Base.bypassAction('TRG_Action2');
// Test only TRG_MyAction
Account account = (Account)TST_Builder.of(Account.SObjectType).build();
// Clear bypasses
TRG_Base.clearAllActionBypasses();
}Query Metadata Configuration
List<TriggerAction__mdt> actions = SEL_TriggerAction.findActiveByObjectNameAndTriggerOperation('Account', TriggerOperation.BEFORE_INSERT);
for(TriggerAction__mdt action : actions)
{
String target = String.isNotBlank(action.ApexClassName__c) ? action.ApexClassName__c : 'Flow:' + action.FlowName__c;
LOG_Builder.build().debug(
action.Order__c + ': ' + target
+ ' | Bypassed: ' + action.BypassExecution__c
+ ' | Allow Recursion: ' + action.AllowRecursion__c
+ ' | Entry Criteria: ' + action.EntryCriteriaFormula__c
).emitAt('Troubleshooting.queryMetadata');
}Related Documentation
- Logging - Guide - Trigger performance logging and correlation tracking
- Validation - Guide - Formula-driven validation via
TRG_ExecuteValidationRules - DML - Guide - DML operations and test data factories
- Selectors - Guide - Query patterns used within trigger actions
- Web Services - Guide - API callout orchestration from trigger contexts
- Security - Guide - The data masking pass that
ApplyMasking__cruns before trigger actions
End of Document