← All posts
May 28, 20269 min read

Building a Bulk-Safe Apex Trigger Dispatcher Framework

One trigger per object, zero business logic in the trigger body, and a handler layer you can actually unit-test. This is the pattern that held up under 1,200 transactions/hour at peak.

ApexArchitectureTriggers

Most trigger problems aren't logic problems — they're structure problems. Logic scattered across multiple triggers on the same object, code that assumes a single record, and recursion that re-fires the same handler three times in one transaction. The fix is boring and durable: a dispatcher framework.

The two rules

Everything below follows from two rules I never break:

  1. One trigger per object. SObject trigger order is undefined when you have more than one. A single trigger removes a whole class of "why did this run before that?" bugs.
  2. No logic in the trigger. The trigger only routes. All behavior lives in a handler class that takes List/Map arguments — which means it's bulk-safe by construction and unit-testable without DML gymnastics.

The trigger

The entire trigger body is one line. It hands control to the dispatcher, which figures out the context and calls the right handler method.

trigger OpportunityTrigger on Opportunity (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    new TriggerDispatcher().run(new OpportunityTriggerHandler());
}

The handler contract

Define an interface so every handler exposes the same hooks. Provide a virtual base class with empty defaults so a handler only overrides the contexts it cares about.

public interface ITriggerHandler {
    void beforeInsert(List<SObject> newList);
    void beforeUpdate(List<SObject> newList, Map<Id, SObject> oldMap);
    void beforeDelete(Map<Id, SObject> oldMap);
    void afterInsert(Map<Id, SObject> newMap);
    void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap);
    void afterDelete(Map<Id, SObject> oldMap);
    void afterUndelete(Map<Id, SObject> newMap);
    Boolean isDisabled();
}

The dispatcher

The dispatcher inspects Trigger context variables and routes. This is also the single place to enforce a global bypass switch and recursion guard.

public class TriggerDispatcher {
    public void run(ITriggerHandler handler) {
        if (handler.isDisabled()) return;

        if (Trigger.isBefore) {
            if (Trigger.isInsert)  handler.beforeInsert(Trigger.new);
            if (Trigger.isUpdate)  handler.beforeUpdate(Trigger.new, Trigger.oldMap);
            if (Trigger.isDelete)  handler.beforeDelete(Trigger.oldMap);
        } else {
            if (Trigger.isInsert)    handler.afterInsert(Trigger.newMap);
            if (Trigger.isUpdate)    handler.afterUpdate(Trigger.newMap, Trigger.oldMap);
            if (Trigger.isDelete)    handler.afterDelete(Trigger.oldMap);
            if (Trigger.isUndelete)  handler.afterUndelete(Trigger.newMap);
        }
    }
}

Recursion guards

The classic failure: an after update handler does a DML that re-fires the same trigger. A static Set<Id> of already-processed records stops the loop without disabling legitimate re-entry.

public class OpportunityTriggerHandler extends TriggerHandlerBase {
    private static Set<Id> processedIds = new Set<Id>();

    public override void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {
        Set<Id> toRun = new Set<Id>();
        for (Id oppId : newMap.keySet()) {
            if (!processedIds.contains(oppId)) toRun.add(oppId);
        }
        if (toRun.isEmpty()) return;
        processedIds.addAll(toRun);

        // ... bulk logic operating only on toRun ...
    }
}
A recursion guard is not a substitute for bulkification. It prevents re-entry; it does not make a per-record SOQL query legal. Both matter.

The bypass switch

Data loads, one-off migrations, and integration users frequently need automation turned off. Drive isDisabled() from a Custom Metadata or Custom Setting so you can flip it per-object or per-user without a deploy.

public virtual Boolean isDisabled() {
    Trigger_Setting__mdt config =
        Trigger_Setting__mdt.getInstance(getHandlerName());
    return config != null && config.Is_Disabled__c;
}

Why this scales

  • Bulk-safe by design — handlers receive collections, so there's nowhere to accidentally write a per-record query.
  • Testable — you can instantiate a handler and call beforeUpdate(newList, oldMap) directly with in-memory sObjects.
  • Predictable — one trigger, one routing path, one place for guards and switches.
  • Operable — flip automation off in production via metadata when an integration goes sideways.

None of this is clever. That's the point. The boring framework is the one that's still standing after three years and 30 custom objects.