- Introduction
- Framework Overview
- Key Components
- Implementation Guide
- Advanced Features
- Best Practices
- Troubleshooting
- Examples
This document provides comprehensive documentation for a custom Salesforce Apex trigger framework. The framework is designed to simplify trigger management, improve code organization, and enhance maintainability of Salesforce applications. It offers a flexible and scalable approach to handling complex trigger logic across multiple objects with built-in support for asynchronous processing.
The framework is built on the following key principles:
- Separation of Concerns: Trigger logic is separated from the trigger itself, allowing for better organization and reusability.
- Configurability: Trigger behavior is controlled through custom metadata, enabling easy management without code changes.
- Extensibility: The framework supports multiple handlers per object and trigger event, allowing for modular and scalable trigger logic.
- Performance Monitoring: Built-in metrics tracking helps identify and optimize performance bottlenecks.
- Asynchronous Execution: Support for asynchronous trigger execution to handle complex or long-running operations.
- Deferred Processing: Ability to queue jobs for later execution when Queueable limits are reached.
The framework consists of several components that work together:
- Trigger Handler Interface: Defines the contract for all trigger handlers
- Trigger Context: Encapsulates all trigger execution information
- Trigger Dispatcher: Central component that manages trigger execution
- Custom Metadata: Configures which handlers run for each object and event
- Performance Metrics: Tracks execution times and resource usage
- Deferred Processing: Handles jobs that exceed governor limits
Create a trigger for your object that calls the TriggerDispatcher:
trigger AccountTrigger on Account (before insert, after insert, before update, after update, before delete, after delete, after undelete) {
TriggerDispatcher.run(Account.SObjectType);
}This simple trigger delegates all processing to the framework. There's no need to write any logic directly in the trigger file.
Create a class that implements the ITriggerExecutable interface for each piece of trigger logic you want to execute:
public class AccountTriggerHandler implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.beforeInsert()) {
handleBeforeInsert(context);
} else if (context.afterUpdate()) {
handleAfterUpdate(context);
}
}
private void handleBeforeInsert(TriggerContext context) {
List<SObject> records = context.getRecords();
for (SObject record : records) {
Account acc = (Account)record;
// Handle before insert logic
}
}
private void handleAfterUpdate(TriggerContext context) {
// In synchronous mode
if (!context.isAsyncMode) {
List<SObject> records = context.getRecords();
// Process records
}
// In asynchronous mode
else {
Set<Id> recordIds = context.getRecordIds();
// Process record IDs
}
}
}The TriggerContext object provides helpful methods to:
- Determine the current trigger operation (
beforeInsert(),afterUpdate(), etc.) - Access records being processed (
getRecords()) - Check if fields have changed (
isChanged())
Create TriggerFeature__mdt records for each trigger handler:
- Go to Setup > Custom Metadata Types
- Click "Manage Records" next to
TriggerFeature__mdt - Click "New"
- Fill in the details:
- Label: A descriptive name (e.g., "Account Before Insert Handler")
- DeveloperName: A unique API name (e.g., "Account_Before_Insert_Handler")
- Handler__c: The full name of your handler class (e.g., "AccountTriggerHandler")
- IsActive__c: Check this to enable the handler
- LoadOrder__c: Set the execution order (lower numbers execute first)
- Check appropriate trigger events (BeforeInsert__c, AfterUpdate__c, etc.)
- SObjectName__c: The API name of the object (e.g., "Account")
- Asynchronous__c: Check if this should run asynchronously (only for after triggers)
- Save the record
This metadata-driven approach allows you to:
- Enable/disable handlers without code changes
- Control which trigger events each handler responds to
- Define the order of execution when multiple handlers exist
- Configure asynchronous execution for specific handlers
To run a handler asynchronously:
- Set the
Asynchronous__cfield to true in theTriggerFeature__mdtrecord - Ensure your handler can work with the limited context provided in async mode
Example:
public class AsyncAccountHandler implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.isAsyncMode) {
// In async mode, we can only access record IDs
Set<Id> accountIds = context.getRecordIds();
// Query for full records if needed
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
// Process accounts
for (Account acc : accounts) {
// Perform async processing
}
}
}
}Important: Asynchronous execution is not allowed in before triggers. The framework will throw a TriggerDispatcherException if attempted.
When queueable limits are reached during trigger processing, the framework automatically creates DeferredQueueableJob__c records for later processing. This is particularly useful in high-volume scenarios where you might hit the limit of 50 queueable jobs in a transaction.
The deferred job processing feature:
- Automatically creates records for jobs that can't be executed immediately
- Processes these jobs later when queueable slots become available
- Maintains the execution context needed for proper processing
Schedule the processor to run every n minutes.
You can customize the processing interval in the TriggerSettings__c custom setting.
You can control trigger execution based on custom permissions:
- Create custom permissions in Setup
- Assign them to permission sets
- Reference them in your trigger features:
BypassPermission__c: If the user has this permission, the handler will not executeRequiredPermission__c: The user must have this permission for the handler to execute
This feature is useful for:
- Allowing administrators to bypass triggers during data loads
- Restricting certain trigger functionality to specific user groups
- Implementing feature toggles based on permissions
The framework includes built-in performance tracking to help identify bottlenecks. Enable this feature in the TriggerSettings__c custom setting.
Once enabled, the framework will log detailed metrics to the TriggerMetric__c object for each trigger handler execution, including:
- Execution time in milliseconds
- CPU time consumed
- DML rows used
- Query rows used
- Number of records processed
You can use this data to:
- Identify slow-performing trigger handlers
- Monitor resource usage
- Optimize code based on performance metrics
- Track execution patterns over time
You can disable triggers for specific objects using the SObjectTriggerControl__c custom setting.
This completely bypasses all trigger processing for the specified object, which is useful during:
- Data migrations
- Bulk operations
- Testing
- Troubleshooting
- Keep handler logic focused and modular
- Use meaningful names for your handler classes and trigger feature records
- Set appropriate load orders to ensure correct execution sequence
- Use asynchronous execution for long-running operations to avoid governor limits
- Consider the limitations of asynchronous context (limited access to trigger context)
- Regularly review performance metrics to identify optimization opportunities
- Use bypass permissions for admin override capabilities
- Implement unit tests that mock trigger features for better isolation
Common issues and solutions:
-
Handler not executing:
- Check if the
TriggerFeature__mdtrecord is active - Verify the
SObjectName__cfield is correct - Ensure the appropriate trigger event checkbox is selected
- Check if the
-
Asynchronous execution not working:
- Confirm the
Asynchronous__cfield is set to true - Ensure the handler is not configured for before triggers
- Check if queueable limits are being reached
- Confirm the
-
Deferred jobs not processing:
- Verify the
DeferredJobProcessoris scheduled - Check for errors in the jobs by querying
DeferredQueueableJob__c - Review custom settings for proper configuration
- Verify the
-
Performance issues:
- Enable metrics and analyze the
TriggerMetric__crecords - Look for operations that can be bulkified or optimized
- Enable metrics and analyze the
public class AccountNameValidator implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.beforeInsert() || context.beforeUpdate()) {
List<Account> accounts = (List<Account>)context.getRecords();
for (Account acc : accounts) {
if (String.isBlank(acc.Name)) {
acc.Name.addError('Account name cannot be blank');
}
}
}
}
}TriggerFeature__mdt record:
- DeveloperName: Account_Name_Validator
- Handler__c: AccountNameValidator
- IsActive__c: True
- LoadOrder__c: 10
- BeforeInsert__c: True
- BeforeUpdate__c: True
- SObjectName__c: Account
First handler:
public class AccountIndustryDefaulter implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.beforeInsert()) {
for (Account acc : (List<Account>)context.getRecords()) {
if (String.isBlank(acc.Industry)) {
acc.Industry = 'Other';
}
}
}
}
}Second handler:
public class AccountRelatedContactCreator implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.afterInsert()) {
List<Contact> newContacts = new List<Contact>();
for (Account acc : (List<Account>)context.getRecords()) {
newContacts.add(new Contact(
AccountId = acc.Id,
LastName = 'Primary Contact'
));
}
if (!newContacts.isEmpty()) {
insert newContacts;
}
}
}
}Configure separate TriggerFeature__mdt records for each handler with different load orders.
public class AccountAsyncProcessor implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.afterUpdate()) {
// In asynchronous mode, we only have access to record IDs
if (context.isAsyncMode) {
Set<Id> accountIds = context.getRecordIds();
// Query for the data we need
List<Account> accounts = [
SELECT Id, Name, Industry, BillingCity
FROM Account
WHERE Id IN :accountIds
];
// Process accounts asynchronously
processAccounts(accounts);
}
// In synchronous mode, we have full access to trigger context
else {
List<Account> accounts = (List<Account>)context.getRecords();
// Check which accounts had industry changes
List<Account> accountsWithIndustryChanges = new List<Account>();
for (Account acc : accounts) {
if (context.isChanged(acc, Account.Industry)) {
accountsWithIndustryChanges.add(acc);
}
}
// Process only accounts with industry changes
processAccounts(accountsWithIndustryChanges);
}
}
}
private void processAccounts(List<Account> accounts) {
// Process accounts, possibly making callouts or performing other expensive operations
for (Account acc : accounts) {
// Expensive operation here
}
}
}TriggerFeature__mdt record:
- DeveloperName: Account_Async_Processor
- Handler__c: AccountAsyncProcessor
- IsActive__c: True
- LoadOrder__c: 20
- AfterUpdate__c: True
- Asynchronous__c: True
- SObjectName__c: Account
public class OpportunityProcessor implements ITriggerExecutable {
public void execute(TriggerContext context) {
// Using helper methods for operation detection
if (context.beforeInsert()) {
handleBeforeInsert(context);
} else if (context.afterUpdate()) {
handleAfterUpdate(context);
}
}
private void handleBeforeInsert(TriggerContext context) {
List<Opportunity> opps = (List<Opportunity>)context.getRecords();
for (Opportunity opp : opps) {
// Process before insert
}
}
private void handleAfterUpdate(TriggerContext context) {
// Check for field changes
List<Opportunity> oppsWithChanges = new List<Opportunity>();
for (Opportunity opp : (List<Opportunity>)context.getRecords()) {
// Using isChanged method to detect field changes
if (context.isChanged(opp, Opportunity.StageName) ||
context.isChanged(opp, Opportunity.Amount)) {
oppsWithChanges.add(opp);
}
}
// Process opportunities with changes
if (!oppsWithChanges.isEmpty()) {
// Process changes
}
}
}You can organize your trigger logic by creating nested handler classes within a main handler class. This approach can help keep related functionality together while still maintaining separation of concerns:
public with sharing class AccountTriggerHandler implements ITriggerExecutable {
public void execute(TriggerContext context) {
if (context.isInsert && context.isBefore) {
// Handle before insert logic
} else if (context.isUpdate && context.isAfter) {
// Handle after update logic
}
// Add more conditions as needed
}
public class UpdateDescription implements ITriggerExecutable {
public void execute(TriggerContext context) {
for (Account acc : (List<Account>) context.getRecords()) {
// Update description logic
}
}
}
public class InsertContact implements ITriggerExecutable {
public void execute(TriggerContext context) {
// Insert related contact logic
}
}
}When configuring these nested handlers in custom metadata, use the full class path:
- For the main handler:
AccountTriggerHandler - For nested handlers:
AccountTriggerHandler.UpdateDescriptionorAccountTriggerHandler.InsertContact
This approach offers several benefits:
- Keeps related functionality organized in a single file
- Reduces the number of separate class files
- Makes it easier to understand the relationship between handlers
- Allows for shared utility methods across handlers
Each nested class can be configured with its own trigger events, load order, and asynchronous settings through separate metadata records.