Trigger Frameworks

As orgs grow, trigger logic must be ordered, toggled, tested, and kept recursion-safe.

19.1 Why a framework?

As orgs grow, trigger logic must be ordered, toggled, tested, and kept recursion-safe. A trigger handler framework gives each object a single trigger and a handler class with one method per event.

19.2 A minimal, production-ready handler base class

public virtual inherited sharing class TriggerHandler {
    private static Set<String> bypassed = new Set<String>();
    private static Map<String, Integer> loopCounts = new Map<String, Integer>();
    @TestVisible private System.TriggerOperation context;

    public TriggerHandler() {
        this.context = Trigger.operationType;
    }

    public void run() {
        String name = getHandlerName();
        if (bypassed.contains(name) || !isEnabled(name)) return;
        Integer count = loopCounts.containsKey(name) ? loopCounts.get(name) : 0;
        if (count >= maxLoops()) return;                      // recursion guard
        loopCounts.put(name, count + 1);

        switch on context {
            when BEFORE_INSERT  { beforeInsert(Trigger.new); }
            when BEFORE_UPDATE  { beforeUpdate(Trigger.new, Trigger.oldMap); }
            when BEFORE_DELETE  { beforeDelete(Trigger.old, Trigger.oldMap); }
            when AFTER_INSERT   { afterInsert(Trigger.new, Trigger.newMap); }
            when AFTER_UPDATE   { afterUpdate(Trigger.new, Trigger.oldMap); }
            when AFTER_DELETE   { afterDelete(Trigger.old, Trigger.oldMap); }
            when AFTER_UNDELETE { afterUndel
…

19.3 A concrete handler

Popular open-source frameworks: Kevin O'Hara's TriggerHandler (lightweight, similar to the above), fflib Domain layer (fflib_SObjectDomain, §35), Trigger Actions Framework by Mitch Spano (metadata-driven ordering of Apex and Flow actions), and Apex Trigger Framework (TDTM) from Salesforce.org's NPSP.

trigger AccountTrigger on Account (before insert, before update, after insert, after update,
                                   before delete, after delete, after undelete) {
    new AccountTriggerHandler().run();
}

public with sharing class AccountTriggerHandler extends TriggerHandler {
    protected override void beforeInsert(List<SObject> newList) {
        AccountService.setDefaults((List<Account>) newList);
    }

    protected override void afterUpdate(List<SObject> newList, Map<Id, SObject> oldMap) {
        List<Account> changed = new List<Account>();
        for (Account a : (List<Account>) newList) {
            Account old = (Account) oldMap.get(a.Id);
            if (a.BillingCountry != old.BillingCountry) changed.add(a);
        }
        if (!changed.isEmpty()) AccountService.syncContactCountries(changed);
    }
}

19.4 Recursion control

Recursion happens when a trigger's DML causes the same trigger to fire again (e.g., an after-update trigger updates the same records, or workflow field updates re-fire update triggers).

Avoid the naive static Boolean firstRun = true flag: when more than 200 records are saved, the trigger runs once per 200-record chunk in the same transaction and the flag would skip every chunk after the first. Track processed record IDs (or compare old and new field values) instead.

public class RecursionGuard {
    private static Set<Id> processed = new Set<Id>();

    // returns only IDs not yet processed in this transaction
    public static Set<Id> filterNew(Set<Id> ids) {
        Set<Id> fresh = new Set<Id>();
        for (Id i : ids) {
            if (!processed.contains(i)) fresh.add(i);
        }
        processed.addAll(fresh);
        return fresh;
    }
}