← All posts
· 5 min read ·
ApexSalesforceArchitectureBest Practices

Apex Trigger Frameworks: Building a Scalable Handler Architecture

One trigger per object, a handler base class, and a registration pattern. Here's how to build a trigger framework that scales without fighting itself.

Circuit board representing code architecture and frameworks

Triggers are where a lot of Salesforce technical debt accumulates. A single Account trigger that’s grown to 800 lines of mixed concerns, bypassed by a tangle of static boolean flags, tested only at the integration level - this is the state most mature orgs end up in. A trigger framework doesn’t prevent bad code, but it establishes a structure that makes good code easier and makes the bad code visible.

The One-Trigger-Per-Object Rule

This is the non-negotiable starting point. Salesforce allows multiple triggers on the same object, but trigger execution order within the same event is non-deterministic. If you have three before insert triggers on Opportunity, you cannot rely on them running in any particular order.

The rule: one trigger per object, period. The trigger itself contains no logic - it delegates entirely to a handler.

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

That’s the entire trigger. No logic, no conditions, no bypass flags. Everything lives in the handler.

Why Trigger Frameworks Exist

Without a framework, every object ends up with its own trigger structure invented by whoever wrote it first. The problems compound:

  • No consistent way to bypass triggers in tests (leading to brittle setups)
  • No bulkification discipline - logic written for single records breaks in bulk data operations
  • No clear place to add cross-cutting concerns (logging, recursion prevention)
  • Impossible to disable specific handlers in production without a deployment

A framework gives you a single, understood pattern across all objects.

The TriggerHandler Base Class

The core pattern is a base class with virtual methods for each trigger event:

public virtual class TriggerHandler {

    private static Set<String> bypassedHandlers = new Set<String>();
    private Boolean isTriggerExecuting;
    protected Integer loopCount;
    protected Integer maxLoopCount;

    public TriggerHandler() {
        this.isTriggerExecuting = true;
        this.loopCount = 0;
        this.maxLoopCount = 5;
    }

    public void run() {
        if (!isTriggerExecuting) return;
        if (isBypassed(getHandlerName())) return;
        if (loopCount >= maxLoopCount) {
            throw new TriggerHandlerException(
                getHandlerName() + ' has exceeded its maximum loop count of ' + maxLoopCount
            );
        }
        loopCount++;

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

    protected virtual void beforeInsert(List<SObject> newRecords) {}
    protected virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
    protected virtual void beforeDelete(List<SObject> oldRecords) {}
    protected virtual void afterInsert(List<SObject> newRecords) {}
    protected virtual void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
    protected virtual void afterDelete(List<SObject> oldRecords) {}
    protected virtual void afterUndelete(List<SObject> newRecords) {}

    public static void bypass(String handlerName) {
        bypassedHandlers.add(handlerName);
    }

    public static void clearBypass(String handlerName) {
        bypassedHandlers.remove(handlerName);
    }

    public static Boolean isBypassed(String handlerName) {
        return bypassedHandlers.contains(handlerName);
    }

    private String getHandlerName() {
        return String.valueOf(this).substring(0, String.valueOf(this).indexOf(':'));
    }

    public class TriggerHandlerException extends Exception {}
}

Implementing a Handler

Each object gets a concrete handler class that extends the base. Only override the methods you need:

public class AccountTriggerHandler extends TriggerHandler {

    private List<Account> newAccounts;
    private Map<Id, Account> oldAccountMap;

    public AccountTriggerHandler() {
        this.newAccounts = (List<Account>) Trigger.new;
        this.oldAccountMap = (Map<Id, Account>) Trigger.oldMap;
    }

    protected override void beforeInsert(List<SObject> newRecords) {
        List<Account> accounts = (List<Account>) newRecords;
        AccountService.setDefaultValues(accounts);
        AccountService.validateRequiredFields(accounts);
    }

    protected override void afterInsert(List<SObject> newRecords) {
        List<Account> accounts = (List<Account>) newRecords;
        AccountService.createDefaultContacts(accounts);
        AccountService.syncToExternalSystem(accounts);
    }

    protected override void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {
        List<Account> accounts = (List<Account>) newRecords;
        Map<Id, Account> oldAccounts = (Map<Id, Account>) oldMap;

        // Only process records where the relevant field changed
        List<Account> changedAccounts = new List<Account>();
        for (Account acc : accounts) {
            Account oldAcc = oldAccounts.get(acc.Id);
            if (acc.Rating != oldAcc.Rating) {
                changedAccounts.add(acc);
            }
        }

        if (!changedAccounts.isEmpty()) {
            AccountService.handleRatingChange(changedAccounts, oldAccounts);
        }
    }
}

Bulkification in Handlers

The handler receives the full Trigger.new list - always. Handlers must be written to process all records in bulk. This means:

  • No SOQL inside loops
  • Collect all IDs first, query once, build a map, then iterate
  • Collect DML operations into lists, execute after the loop
protected override void afterInsert(List<SObject> newRecords) {
    List<Account> accounts = (List<Account>) newRecords;

    // Collect IDs for a single query
    Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();

    // Single query outside any loop
    Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
    for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
        if (!contactsByAccount.containsKey(c.AccountId)) {
            contactsByAccount.put(c.AccountId, new List<Contact>());
        }
        contactsByAccount.get(c.AccountId).add(c);
    }

    // Build DML list
    List<Task> tasksToInsert = new List<Task>();
    for (Account acc : accounts) {
        List<Contact> contacts = contactsByAccount.get(acc.Id);
        if (contacts != null && !contacts.isEmpty()) {
            tasksToInsert.add(new Task(
                Subject = 'Welcome call',
                WhoId = contacts[0].Id,
                WhatId = acc.Id,
                ActivityDate = Date.today().addDays(3)
            ));
        }
    }

    if (!tasksToInsert.isEmpty()) {
        insert tasksToInsert;
    }
}

Bypassing Triggers in Tests

The static bypass mechanism makes test setup clean:

@IsTest
static void testAccountWithoutTriggerSideEffects() {
    TriggerHandler.bypass('AccountTriggerHandler');

    Account acc = new Account(Name = 'Test Account');
    insert acc;

    TriggerHandler.clearBypass('AccountTriggerHandler');

    // Now test specific behavior without interference from
    // createDefaultContacts or syncToExternalSystem
}

This is far cleaner than the old AccountTriggerHandler.bypassTrigger = true static boolean pattern, which required knowing the handler class name at the test level.

Custom Framework vs fflib

fflib (the Force.com Enterprise Patterns library) is a comprehensive alternative that includes its own trigger dispatcher, domain layer, and unit of work pattern. It’s well-maintained and battle-tested, but it has a steep learning curve and brings opinions about your entire application architecture, not just triggers.

Build your own framework when:

  • You want minimal dependencies
  • The team is comfortable with Apex but not fflib
  • Your org’s complexity doesn’t justify the full enterprise pattern stack

Use fflib when:

  • You’re building a large ISV product
  • You want the full Domain/Selector/Service separation enforced by the framework
  • The team has fflib experience and you’re starting from scratch

For most customer orgs, a custom TriggerHandler base class hits the sweet spot. It’s 100 lines of code that solves 80% of the structural problems.

← All posts