One Trigger per Object, Handler Pattern, and Recursion Guards in Salesforce Apex

Share

In Salesforce Apex, triggers are incredibly powerful — but without structure, they can spiral into chaos fast.
To keep your automation clean, scalable, and predictable, follow three cornerstone practices:

  1. One trigger per object

  2. A trigger handler pattern

  3. Recursion guards

Together, these principles make your code easier to debug, safer to scale, and far more maintainable across complex orgs.


? One Trigger per Object

You should always have a single trigger for each sObject — for example, one AccountTrigger, one OpportunityTrigger, etc.

Why it matters:
When multiple triggers exist on the same object, Salesforce doesn’t guarantee the order in which they run. That leads to unpredictable behavior, debugging headaches, and potential automation conflicts.

A single trigger becomes your central entry point, routing all logic in a controlled, predictable way.

Key benefits:

  • Deterministic flow and easier debugging

  • Centralized enable/disable controls and recursion guards

  • Cleaner unit tests and continuous integration pipelines


? The Trigger Handler Pattern

The goal of the handler pattern is simple: keep triggers tiny.

Move your business logic into a separate handler class (or a lightweight framework).
Your trigger should only delegate to handler methods based on context — before/after, insert/update/delete/undelete.

The handler should encapsulate:

  • Bulk-safe logic (looping through collections, grouped SOQL/DML)

  • Context-specific rules (beforeInsert, afterUpdate, etc.)

  • Shared utilities for validation, batching, or error management

This separation of responsibilities gives you:

  • Reusable, testable business logic

  • Consistent trigger behavior

  • Cleaner code and easier debugging


? Recursion Guards

Triggers can re-enter themselves when your code performs DML on the same object inside a trigger (like updating an Account in an after update), or indirectly through flows, processes, or platform events.

To prevent infinite loops and duplicate actions, use recursion guards — small static variables or ID sets that track what’s already been processed in the same transaction.

Common patterns:

  • Static Boolean: Simple “has this run already?” flag — great for coarse-grained control.

  • Static Set<Id>: Tracks specific record IDs — perfect when you need to handle bulk updates without duplication.


? Real-World Example

Scenario

When an Account’s AnnualRevenue crosses a defined threshold:

  • Mark it as High Revenue (High_Revenue__c = true)

  • Create one follow-up Task for the Account owner

  • Ensure no duplicate Tasks are created, even if the trigger re-enters

We’ll do this with:

  • A single AccountTrigger

  • An AccountTriggerHandler class

  • A TriggerRecursionGuard utility

Notes:

  • Fully bulkified — handles many Accounts at once.

  • Recursion-safe — guards prevent double processing.

  • Any same-object DML is done cautiously in after context; in real projects, prefer before updates where possible.


The Single Trigger (Delegates Only)

// AccountTrigger.trigger
trigger AccountTrigger on Account (
    before insert, before update,
    after insert,  after update
) {
    AccountTriggerHandler handler = new AccountTriggerHandler();

    if (Trigger.isBefore && Trigger.isInsert)  handler.beforeInsert(Trigger.new);
    if (Trigger.isBefore && Trigger.isUpdate)  handler.beforeUpdate(Trigger.oldMap, Trigger.newMap);

    if (Trigger.isAfter  && Trigger.isInsert)  handler.afterInsert(Trigger.new);
    if (Trigger.isAfter  && Trigger.isUpdate)  handler.afterUpdate(Trigger.oldMap, Trigger.newMap);
}

The Handler (Bulkified Business Logic)

// AccountTriggerHandler.cls
public with sharing class AccountTriggerHandler {
    private static final Decimal REVENUE_THRESHOLD = 1000000; // 1M

    public void beforeInsert(List<Account> newList) {
        // Example: set defaults in before-insert without extra DML
        for (Account a : newList) {
            if (a.AnnualRevenue != null && a.AnnualRevenue >= REVENUE_THRESHOLD) {
                a.High_Revenue__c = true; // no recursion risk in "before"
            }
        }
    }

    public void beforeUpdate(Map<Id, Account> oldMap, Map<Id, Account> newMap) {
        // Example: adjust fields directly to avoid after-update DML when possible
        for (Id accId : newMap.keySet()) {
            Account oldA = oldMap.get(accId);
            Account newA = newMap.get(accId);

            Boolean crossedUp =
                (oldA.AnnualRevenue == null || oldA.AnnualRevenue < REVENUE_THRESHOLD) &&
                (newA.AnnualRevenue != null && newA.AnnualRevenue >= REVENUE_THRESHOLD);

            if (crossedUp) {
                newA.High_Revenue__c = true; // handled in before
            }
        }
    }

    public void afterInsert(List<Account> newList) {
        createFollowUpTasksForHighRevenue(newList);
    }

    public void afterUpdate(Map<Id, Account> oldMap, Map<Id, Account> newMap) {
        // Only consider those that just crossed the threshold or now flagged
        List<Account> crossed = new List<Account>();
        for (Id accId : newMap.keySet()) {
            Account oldA = oldMap.get(accId);
            Account newA = newMap.get(accId);

            Boolean crossedUp =
                (oldA.AnnualRevenue == null || oldA.AnnualRevenue < REVENUE_THRESHOLD) &&
                (newA.AnnualRevenue != null && newA.AnnualRevenue >= REVENUE_THRESHOLD);

            if (crossedUp || (newA.High_Revenue__c == true && oldA.High_Revenue__c != true)) {
                crossed.add(newA);
            }
        }
        createFollowUpTasksForHighRevenue(crossed);
    }

    private void createFollowUpTasksForHighRevenue(List<Account> accounts) {
        if (accounts.isEmpty()) return;

        // Guard: avoid creating duplicate tasks in the same transaction
        List<Task> toInsert = new List<Task>();
        for (Account a : accounts) {
            if (a.Id == null) continue;

            // Fine-grained guard: only process each Account once per transaction
            if (!TriggerRecursionGuard.enter('AccountHighRevenueTask', a.Id)) {
                continue; // already processed
            }

            Task t = new Task(
                WhatId = a.Id,
                OwnerId = a.OwnerId,
                Subject = 'Follow up on high-revenue account',
                Priority = 'High',
                Status = 'Not Started'
            );
            toInsert.add(t);
        }
        if (!toInsert.isEmpty()) {
            insert toInsert;
        }
    }
}

The Recursion Guard (Static Memory per Transaction)

// TriggerRecursionGuard.cls
public with sharing class TriggerRecursionGuard {
    // Namespacing by "key" lets you use the same guard utility for different flows
    private static Map<String, Set<Id>> processedByKey = new Map<String, Set<Id>>();

    // Returns true if this (key, id) has NOT been processed yet and marks it as processed.
    public static Boolean enter(String key, Id recordId) {
        if (recordId == null) return false;
        if (!processedByKey.containsKey(key)) {
            processedByKey.put(key, new Set<Id>());
        }
        Set<Id> bucket = processedByKey.get(key);
        if (bucket.contains(recordId)) {
            return false; // already processed in this transaction
        }
        bucket.add(recordId);
        return true;
    }

    // Optional: reset for test methods
    @TestVisible
    static void reset() {
        processedByKey.clear();
    }
}

Trigger

⚙️ Why This Works

  • One Trigger per Object: All Account logic runs through a single, predictable entry point.

  • Handler Pattern: Keeps the business logic modular, reusable, and bulk-safe.

  • Recursion Guard: Prevents duplicate task creation and runaway re-entry.

This structure gives you clean, testable, and production-ready triggers — ready for complex automation without chaos.


? Final Thoughts

A well-structured trigger setup isn’t optional — it’s the foundation of stable Apex architecture.

  • Stick to one trigger per object for predictability.

  • Adopt a handler pattern to separate logic from context.

  • Use recursion guards (static sets or flags) to keep transactions safe and idempotent.

These three best practices form the backbone of enterprise-grade, maintainable Apex trigger design.

  • October 21, 2025