One Trigger per Object, Handler Pattern, and Recursion Guards in Salesforce Apex
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:
-
One trigger per object
-
A trigger handler pattern
-
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
aftercontext; in real projects, preferbeforeupdates 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();
}
}

⚙️ 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.
