Custom Metadata Driven Trigger Framework

With this Trigger Framework, you can control the execution of Triggers on all Events through Custom Metadata. This is just a code block and not a Package. Perfect for troubleshooting. The code is highly optimized and can be further customized accordingly. With this Framework we can disable Triggers in Production declaratively.

Benefits

You can disable entire Triggers in the org OR disable specific SObject Triggers (Account, Custom_Object__c etc.). Individual SObject Trigger Events (before update, after insert, before delete etc.) can also be controlled.

One time setup/org. Not a Package. No installation required. Just deploy the below classes from VS Code. And since this Framework uses Custom Metadata and not Custom Settings, the configuration can be deployed along with the package.

Create the required Metadata Record, the Trigger and its Handler Class. Rest of the code will remain the same. Trigger Dispatcher Class controls whether or not to fire the Trigger Event based on Custom Metadata Setting.

In order for the Trigger to work, a metadata record with the same name as Trigger must exist. If the metadata record is not found the Trigger will fail.

A layer of Abstraction has been added through private methods and Trigger.isExecuting Boolean. The code in the Handler Methods will only work if called from a Trigger and not directly. However, that can be changed in its constructor.

The developer, works only on the Individual Trigger Handlers and the Trigger itself. However, if you want to run code bypassing the Trigger Framework, it can be added directly in the Trigger or in a separate Handler Method.

Creating Custom Metadata

In this framework, we are creating 3 custom metadata records. One to control Org Triggers, the second for Apex Triggers and the third for Flow Triggers (if required).

To Control Org Wide Triggers

Custom MDT Details

Custom Metadata Type Name: OrgTriggerSettings
API Name: OrgTriggerSettings__mdt

Custom Fields

Field LabelAPI NameData Type
EnabledEnabled__cCheckbox (Default Checked)
ApexTriggersApexTriggers__cCheckbox (Default Checked)
FlowTriggersFlowTriggers__cCheckbox (Default Checked)

To Control Apex Triggers

Custom MDT Details

Custom Metadata Type Name: ApexTriggerSettings
API Name: ApexTriggerSettings__mdt

Custom Fields

Field LabelAPI NameData Type
triggerIsActivetriggerIsActive__cCheckbox (Default Checked)
beforeInsertbeforeInsert__cCheckbox (Default Checked)
afterInsertafterInsert__cCheckbox (Default Checked)
beforeUpdatebeforeUpdate__cCheckbox (Default Checked)
afterUpdateafterUpdate__cCheckbox (Default Checked)
beforeDeletebeforeDelete__cCheckbox (Default Checked)
afterDeleteafterDelete__cCheckbox (Default Checked)
afterUnDeleteafterUnDelete__cCheckbox (Default Checked)

To Control Flow Triggers

Custom MDT Details

Custom Metadata Type Name: FlowTriggerSettings
API Name: FlowTriggerSettings__mdt

Custom Fields

Same as the fields in the ApexTriggerSettings__mdt. However, the last 2 can be ignored since they are not yet supported by flows.

Creating Metadata Records

OrgTriggerSettings

MDT NameEnabledApexTriggersFlowTriggers
Triggers

ApexTriggerSettings

Note that the record name should be same as the Trigger Class name.

MDT NametriggerIsActivebeforeInsertafterInsertbeforeUpdateafterUpdatebeforeDeleteafterDeleteafterUnDelete
AccountTrigger
ContactTrigger
OpportunityTrigger

FlowTriggerSettings

Copy the same records as in ApexTriggerSettings Metadata (if Required). However, we are not going to use this metadata records in the Apex. You can use these records to control the execution of flows.

Trigger Handler Interface

This interface handles all Trigger events. Declared methods in this interface have no body. The Individual Trigger Handler classes have to implement this interface and all the methods must be used along with the body { }.

// Handle All Events Related to Triggers.
public interface TriggerHandler {
	void beforeInsert(List<SObject> newRecords);
    void afterInsert(Map<Id, SObject> newRecordsMap);
    void beforeUpdate(Map<Id, SObject> newRecordsMap, Map<Id, sObject> oldRecordsMap);
    void afterUpdate(Map<Id, SObject> newRecordsMap, Map<Id, sObject> oldRecordsMap);
    void beforeDelete(Map<Id, SObject> oldRecordsMap);
    void afterDelete(Map<Id, SObject> oldRecordsMap);
    void afterUnDelete(Map<Id, SObject> newRecordsMap);
}

Trigger Dispatcher

This class is the one that controls the logic of the framework. Depending on the metadata selected Trigger context variables are passed to the handler methods.

An early return pattern has been implemented. If Org triggers are disabled or if individual Triggers are disabled, the dispatcher won’t pass the values to the handler methods.

public without sharing class TriggerDispatcher {
    public static void run(TriggerHandler handler, String triggerName) {
        
        // Check if ORG Triggers are Disabled. If Disabled, return early.
        Boolean triggersEnabled = OrgTriggerSettings__mdt.getInstance('Triggers').Enabled__c && OrgTriggerSettings__mdt.getInstance('Triggers').ApexTriggers__c;
        if (!triggersEnabled){
            System.debug('METADATA ERROR: ORG Triggers are Disabled');
            return;
        }
        
        // Check if Individual Trigger is Active. If Disabled, return early.
        Boolean triggerIsActive = ApexTriggerSettings__mdt.getInstance(triggerName).triggerIsActive__c;
        if (!triggerIsActive){
            System.debug('METADATA ERROR: Individual Trigger is Disabled');
            return;
        }
        
        // Check if Trigger Events are Enabled
        Boolean beforeInsert = ApexTriggerSettings__mdt.getInstance(triggerName).beforeInsert__c;
        Boolean beforeUpdate = ApexTriggerSettings__mdt.getInstance(triggerName).beforeUpdate__c;
        Boolean beforeDelete = ApexTriggerSettings__mdt.getInstance(triggerName).beforeDelete__c;
        Boolean afterInsert = ApexTriggerSettings__mdt.getInstance(triggerName).afterInsert__c;
        Boolean afterUpdate = ApexTriggerSettings__mdt.getInstance(triggerName).afterUpdate__c;
        Boolean afterDelete = ApexTriggerSettings__mdt.getInstance(triggerName).afterDelete__c;
        Boolean afterUnDelete = ApexTriggerSettings__mdt.getInstance(triggerName).afterUnDelete__c;
        
        // Passing the Trigger Context Variables to the Handler Methods
        switch on Trigger.operationType {
            when BEFORE_INSERT {
                if(beforeInsert) handler.beforeInsert(Trigger.new);
            }
            when BEFORE_UPDATE {
                if(beforeUpdate) handler.beforeUpdate(Trigger.newMap, Trigger.oldMap);
            }
            when BEFORE_DELETE {
                if(beforeDelete) handler.beforeDelete(Trigger.oldMap);
            }
            when AFTER_INSERT {
                if(afterInsert) handler.afterInsert(Trigger.newMap);
            }
            when AFTER_UPDATE {
                if(afterUpdate) handler.afterUpdate(Trigger.newMap, Trigger.oldMap);
            }
            when AFTER_DELETE {
                if(afterDelete) handler.afterDelete(Trigger.oldMap);
            }
            when AFTER_UNDELETE {
                if(afterUnDelete) handler.afterUnDelete(Trigger.newMap);
            }
        }
        
        // We can also use the Standard if-else instead of Switch on ENUM
        /*
        if(Trigger.isBefore) {
            if(Trigger.isInsert && beforeInsert) handler.beforeInsert(Trigger.new);
            if(Trigger.isUpdate && beforeUpdate) handler.beforeUpdate(Trigger.newMap, Trigger.oldMap);
            if(Trigger.isDelete && beforeDelete) handler.beforeDelete(Trigger.oldMap);
        } else if(Trigger.isAfter) {
            if(Trigger.isInsert && afterInsert) handler.afterInsert(Trigger.newMap);
            if(Trigger.isUpdate && afterUpdate) handler.afterUpdate(Trigger.newMap, Trigger.oldMap);
            if(Trigger.isDelete && afterDelete) handler.afterDelete(Trigger.oldMap);
            if(Trigger.isUnDelete && afterUnDelete) handler.afterUnDelete(Trigger.newMap);
        }*/
    }
}

Individual Trigger Handlers

Below handler has been written for the Account SObject. In VS Code or Notepad++, replace (Ctrl+H) the instance of ‘Account’ with the SObject name the handler is for.

In the individual private logic methods, typecasting is required before utilizing the SObject records.

The individual methods call the logic methods only if the Trigger is invoked. This means, you cannot call this code directly by creating an instance of the class. The ‘pass’ Boolean variable will be false by default.

public with sharing class AccountTriggerHandler implements TriggerHandler {
    // Declare 'true' if you want to call this code directly other than Triggers
    public Boolean pass = false; 
    public AccountTriggerHandler() {
        if(Trigger.isExecuting) this.pass = true;
        else System.debug('Only Triggers can Execute this Code');
    }
    // Implement all Methods of the Interface
    public void beforeInsert(List<SObject> newRecords) {if(this.pass) beforeInsertEvents(newRecords);}
    public void afterInsert(Map<Id, SObject> newRecordsMap) {if(this.pass) afterInsertEvents(newRecordsMap);}
    public void beforeUpdate(Map<Id, SObject> newRecordsMap, Map<Id, SObject> oldRecordsMap) {if(this.pass) beforeUpdateEvents(newRecordsMap,oldRecordsMap);}
    public void afterUpdate(Map<Id, SObject> newRecordsMap, Map<Id, SObject> oldRecordsMap) {if(this.pass) afterUpdateEvents(newRecordsMap,oldRecordsMap);}
    public void beforeDelete(Map<Id, SObject> oldRecordsMap) {if(this.pass) beforeDeleteEvents(oldRecordsMap);}
    public void afterDelete(Map<Id, SObject> oldRecordsMap) {if(this.pass) afterDeleteEvents(oldRecordsMap);}
    public void afterUnDelete(Map<Id, SObject> newRecordsMap) {if(this.pass) afterUnDeleteEvents(newRecordsMap);}
    
    // Handle Trigger Events Logic
    // beforeInsertEvents
    void beforeInsertEvents(List<SObject> newSOValues) {
        List<Account> newRecords = (List<Account>)newSOValues; // Typecasting
        // Logic
        for(Account acc: newRecords) {            
            // if(acc.Industry == null) acc.Industry = 'Banking';
            System.debug('Current Account Record: '+acc);
        }
    }
    // afterInsertEvents
    void afterInsertEvents(Map<Id, SObject> newSOValuesMap) {
        Map<Id, Account> newRecordsMap = (Map<Id, Account>)newSOValuesMap; // Typecasting
        // Logic
        List<Contact> conList = new List<Contact>();
        for(Account acc: newRecordsMap.values()) {
            Contact con = new Contact();
            con.LastName = acc.Name;
            con.put('AccountId', acc.Id);
            con.put('Description','Created from Trigger After Insert');
            conList.add(con);
            System.debug('About to create Contact Record: '+con);
        }
        // if(!conList.isEmpty()) insert conList;
    }
    // beforeUpdateEvents
    void beforeUpdateEvents(Map<Id, SObject> newSOValuesMap, Map<Id, SObject> oldSOValuesMap) {
        Map<Id, Account> newRecordsMap = (Map<Id, Account>)newSOValuesMap;
        Map<Id, Account> oldRecordsMap = (Map<Id, Account>)oldSOValuesMap;
    }
    // afterUpdateEvents
    void afterUpdateEvents(Map<Id, SObject> newSOValuesMap, Map<Id, SObject> oldSOValuesMap) {
        Map<Id, Account> newRecordsMap = (Map<Id, Account>)newSOValuesMap;
        Map<Id, Account> oldRecordsMap = (Map<Id, Account>)oldSOValuesMap;
    }
    // beforeDeleteEvents
    void beforeDeleteEvents(Map<Id, SObject> oldSOValuesMap) {
        Map<Id, Account> oldRecordsMap = (Map<Id, Account>)oldSOValuesMap;
    }
    // afterDeleteEvents
    void afterDeleteEvents(Map<Id, SObject> oldSOValuesMap) {
        Map<Id, Account> oldRecordsMap = (Map<Id, Account>)oldSOValuesMap;
    }
    // afterUnDeleteEvents
    void afterUnDeleteEvents(Map<Id, SObject> newSOValuesMap) {
        Map<Id, Account> newRecordsMap = (Map<Id, Account>)newSOValuesMap;
    }
}

Individual Trigger

In VS Code or Notepad++, replace (Ctrl+H) the instance of ‘Account’ with the SObject name the Trigger is for. Note that the second string parameter which is passed to the run method of the dispatcher must have the same name as the trigger. Also the Metadata record should also have a record with the same name.

trigger AccountTrigger on Account (before insert, after insert, before update, after update, before delete, after delete, after undelete) {
	TriggerDispatcher.run(new AccountTriggerHandler(), 'AccountTrigger');
    // SomeOtherClass.itsMethod(); That you want to run no matter what. Example an AuditTrailClass.insertRecords();
}

Observations

We are only passing the Trigger.newMap and Trigger.oldMap to our handler methods except for before insert scenario as we do not have an Id.

For beginners, Map<Id, Account> is nothing but a mapping of an Individual Id to the entire Account. Account already has an Id generated. The same Id is being used as a key to create map.

The keySet() method of the map returns the Set of Ids and the values() method returns the List of Accounts. Hence Trigger.new is same as newRecordsMap.values(). Similarly Trigger.old is same as oldRecordsMap.values().

Sample Test Class

// Intention is just to cover the Lines. Not for actual testing
// Not a Best Practice
@isTest class AccountTriggerHandler_Test {    
    @isTest static void testTrigger() {
        Account acc = new Account(Name= 'NewAccount');
        insert acc;
        acc.Description = 'Updated Description';
        update acc;
        delete acc;
        Account a = [SELECT Id FROM Account WHERE isDeleted=true and Id=:acc.Id ALL ROWS];
        undelete a;
        System.assert(true);
    }
}