Skip to content
ForceTricks
Back to blog

Apex Trigger Architecture: Handler Pattern Limits

7 min read
SeriesApex for Enterprise OrgsPart 1 of 1
  1. 1Apex Trigger Architecture: Handler Pattern Limits

Every Salesforce project eventually lands on the same trigger architecture: one trigger per object, all logic delegated to a handler class. The pattern is sound. It's also the first thing that breaks in a genuinely complex org.

This post covers what makes the canonical pattern work, the specific conditions under which it stops working, and the alternatives that actually hold up in orgs with 200+ triggers across multiple packages.

Why the Handler Pattern Works — and the Constraints It Assumes

The standard approach: one trigger per object fires on all events, immediately calls a handler class, and the handler routes logic by context.

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

public with sharing class AccountTriggerHandler extends TriggerHandler {
    public override void beforeUpdate() {
        AccountService.validateOwnershipRules(
            (List<Account>) Trigger.new,
            (Map<Id, Account>) Trigger.oldMap
        );
    }
    public override void afterUpdate() {
        AccountService.syncToERP(Trigger.new, Trigger.oldMap);
    }
}

This works reliably when three assumptions hold:

  1. You control all the triggers on the object. One owner, one deployment pipeline.
  2. Execution order within a transaction is irrelevant. Your handlers don't depend on running before or after another piece of code you don't control.
  3. The recursion guard is enough. A static boolean or a depth counter is sufficient to prevent infinite loops.

In a greenfield org with a single development team, all three assumptions are true. In an enterprise org with managed packages, ISV components, and multiple service layers, none of them are.

Multi-Package Orgs: When Single-Trigger Is Structurally Impossible

When a managed package installs its own trigger on Account, you have two triggers on the same object. You cannot merge them. You cannot control which one fires first in most scenarios. Salesforce processes triggers in the order they were created — but package triggers predate your code, so they run first.

The problem gets worse when you add a customization layer on top of a managed package. Now you have:

  • The managed package trigger (fires first, no visibility)
  • Your customization trigger (fires second, depends on what the package did)
  • Potentially a service layer trigger from an integration tool (fires third)

All three are on Account. All three call their own handlers. None of them know about each other.

The canonical "one trigger per object" ideal is structurally impossible here — you already have three triggers, and you didn't write two of them.

What actually matters in this scenario isn't eliminating multiple triggers. It's controlling what your triggers do relative to what you can't control. The real work is isolating your logic from side effects caused by package code running in the same transaction.

Patterns That Work in High-Complexity Environments

Ordered Dispatcher Pattern

Instead of one handler per object, you register handlers into a dispatcher with explicit ordering. Each handler declares what context it runs in and at what priority.

public with sharing class TriggerDispatcher {
    private static Map<String, List<ITriggerHandler>> registry =
        new Map<String, List<ITriggerHandler>>();

    public static void register(String key, ITriggerHandler handler, Integer order) {
        if (!registry.containsKey(key)) {
            registry.put(key, new List<ITriggerHandler>());
        }
        // Insert in order position
        registry.get(key).add(handler);
        registry.get(key).sort(); // assumes ITriggerHandler implements Comparable
    }

    public static void run(String objectType, System.TriggerOperation op) {
        String key = objectType + '.' + op.name();
        List<ITriggerHandler> handlers = registry.get(key) ?? new List<ITriggerHandler>();
        for (ITriggerHandler h : handlers) {
            h.execute(Trigger.new, Trigger.old, Trigger.newMap, Trigger.oldMap);
        }
    }
}

The benefit: when the team adds a new handler, they declare its position explicitly. You can see the execution order in code, not by guessing at creation dates.

Context-Isolation Pattern

When you have managed package triggers running in the same transaction and you suspect they're modifying records before your code sees them, you stop trusting Trigger.new directly. Instead, you re-query the records after the package trigger has run, using SOQL with FOR UPDATE to get the committed state.

public with sharing class AccountTriggerHandler extends TriggerHandler {
    public override void afterUpdate() {
        // Don't trust Trigger.new after a managed package has run.
        // Re-query with explicit field selection to get current state.
        Set<Id> ids = Trigger.newMap.keySet();
        List<Account> freshRecords = [
            SELECT Id, OwnerId, AccountNumber, ExternalSyncStatus__c
            FROM Account
            WHERE Id IN :ids
            FOR UPDATE
        ];
        AccountService.processPostPackageUpdate(freshRecords, Trigger.oldMap);
    }
}

This costs an extra SOQL per trigger context. In most cases the trade-off is worth it — you stop debugging phantom state that a package introduced.

Using Trigger Order of Execution Deliberately

Salesforce's documented execution order within a transaction is deterministic: validation rules, before triggers, system validations, after triggers, assignment rules, workflow rules, and so on. Most developers treat this as background knowledge. In complex orgs, it becomes a design constraint.

One pattern: defer work to after-trigger context when you need to know whether validation rules passed. Don't check validation logic yourself in before-trigger — let Salesforce do it, and only act on the record in after-trigger when you know the DML succeeded.

Another: use before delete to capture data from child records before cascade rules eliminate them. This is the only reliable place to read that data before it's gone.

Neither of these is exotic. They're the execution order doing useful work instead of being something you fight.

The Recursion Problem That Static Guards Don't Solve

The classic recursion prevention: a static boolean that gets set to true when the trigger first fires, and every subsequent call returns early.

public with sharing class AccountTriggerHandler extends TriggerHandler {
    private static Boolean hasRun = false;

    public static void run() {
        if (hasRun) return;
        hasRun = true;
        // ... logic
    }
}

This works when the recursion is synchronous and happens within a single transaction. It fails in two common scenarios in enterprise orgs.

Async chains. Your trigger fires, enqueues a Queueable, the Queueable updates records, which fires the trigger again — in a separate transaction. The static variable is reset between transactions. The static guard does nothing here.

Future + Platform Event combinations. A @future method publishes a Platform Event, the event fires a trigger, which publishes another Platform Event. Each step is a separate transaction. Recursion across them isn't prevented by any static state.

The actual solution for async recursion: a dedicated custom object or custom metadata record that tracks what the async job processed. Before acting, check the log. This is more work than a static boolean, but it's the only mechanism that survives async context switches.

public with sharing class AccountSyncQueueable implements Queueable {
    private Set<Id> accountIds;

    public AccountSyncQueueable(Set<Id> ids) {
        this.accountIds = ids;
    }

    public void execute(QueueableContext ctx) {
        // Check what we've already processed in this async chain
        Set<Id> alreadyProcessed = SyncProcessingLog__c.getProcessedIds(accountIds);
        Set<Id> toProcess = new Set<Id>(accountIds);
        toProcess.removeAll(alreadyProcessed);

        if (toProcess.isEmpty()) return;

        SyncProcessingLog__c.markInProgress(toProcess);
        AccountService.syncToERP(toProcess);
    }
}

When the Handler Pattern Is the Right Answer

The handler pattern is reliable when:

  • You own all triggers on the object (no managed packages with their own triggers)
  • The org has a single deployment pipeline and one team
  • The execution context is primarily synchronous
  • The team is junior to mid-level — the pattern provides predictable structure without requiring deep knowledge of platform edge cases

In this environment, a well-implemented TriggerHandler framework (Kevin O'Hara's or any equivalent) gives you clean separation, testability, and consistent recursion prevention. Don't introduce dispatcher complexity or context isolation when the simpler structure works.

The mistake is carrying the handler pattern into a context it wasn't designed for and blaming the pattern when it struggles. The pattern isn't wrong — the environment changed.

Practical Takeaways

  • If your org has managed packages with their own triggers → accept multiple triggers per object and focus on isolating your logic from package side effects
  • If trigger execution order matters to your logic → encode it explicitly with a dispatcher instead of relying on creation date order
  • If you have async chains that update records → static recursion guards will not prevent re-entrancy; implement a processing log instead
  • If none of the above applies to your org → the standard handler pattern is the right choice; complexity for its own sake is not an improvement

First post in the "Apex for Enterprise Orgs" series. Next: governor limits — the real strategies, not the Trailhead version.

Have questions about trigger architecture in your org? Find me on LinkedIn.

Gabriel Cruz Ferreira

Gabriel Cruz Ferreira

Salesforce Architect · 15x Certified · Road to CTA

Was this post helpful?