Problem Statement: Poorly designed Apex triggers run multiple times per transaction, causing recursive updates, duplicate records, or unintended field changes due to missing recursion guards, mixed responsibilities, or lack of a trigger framework.
Salesforce Trigger Logic Causing Recursive Updates or Data Duplication
Step-by-Step Fix (with Code)
Why This Happens in Salesforce
Salesforce triggers can run multiple times in a single transaction:
before insertafter insertbefore updateafter update
Recursion and duplication occur when:
- A trigger updates the same object it fires on
- Multiple triggers handle the same logic
- No recursion guard is used
- Logic is scattered across trigger contexts
Common Symptoms
- Duplicate child records created
- Fields updated repeatedly
- CPU time limit exceeded
- “Too many DML statements” errors
- Records updated even when values didn’t change
Step 1 Identify the BAD trigger pattern
WRONG: Updating records inside after update without guard
trigger AccountTrigger on Account (after update) {
for (Account acc : Trigger.new) {
acc.Description = 'Updated';
}
update Trigger.new; // Recursive trigger
}
This re-fires the trigger endlessly.
Step 2 Move logic to a Trigger Handler Framework
Trigger (Single Responsibility)
trigger AccountTrigger on Account (
before insert, before update,
after insert, after update
) {
AccountTriggerHandler.run(
Trigger.operationType,
Trigger.new,
Trigger.oldMap
);
}
- One trigger per object
- No logic inside trigger
Step 3 Add a Recursion Guard (Critical)
Static Boolean Guard
public class TriggerControl {
public static Boolean isRunning = false;
}
Step 4 Implement Guarded Logic in Handler
public class AccountTriggerHandler {
public static void run(
System.TriggerOperation operation,
List<Account> newList,
Map<Id, Account> oldMap
) {
if (TriggerControl.isRunning) return;
TriggerControl.isRunning = true;
if (operation == System.TriggerOperation.BEFORE_UPDATE) {
beforeUpdate(newList, oldMap);
}
if (operation == System.TriggerOperation.AFTER_INSERT) {
afterInsert(newList);
}
TriggerControl.isRunning = false;
}
}
- Prevents recursive execution
- Centralized control
Step 5 Only Update Records When Data Actually Changes
BAD
acc.Status__c = 'Active';
Runs every time → recursion.
GOOD
if (acc.Status__c != 'Active') {
acc.Status__c = 'Active';
}
- Prevents unnecessary updates
- Reduces trigger re-fires
Step 6 Never Insert Child Records Without Duplication Checks
BAD (duplicates)
insert new Contact(
LastName = 'Auto',
AccountId = acc.Id
);
GOOD (check existing records)
Map<Id, Integer> contactCountMap = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT AccountId accId, COUNT(Id) cnt
FROM Contact
WHERE AccountId IN :accountIds
GROUP BY AccountId
]) {
contactCountMap.put((Id) ar.get('accId'), (Integer) ar.get('cnt'));
}
List<Contact> contactsToInsert = new List<Contact>();
for (Account acc : newList) {
if (!contactCountMap.containsKey(acc.Id)) {
contactsToInsert.add(
new Contact(LastName = 'Auto', AccountId = acc.Id)
);
}
}
insert contactsToInsert;
- No duplicates
- Bulk-safe
Step 7 Use before Triggers Instead of after When Possible
BAD
after update → update same record
GOOD
before update → modify Trigger.new
if (acc.Score__c == null) {
acc.Score__c = 0;
}
- No DML
- No recursion
Step 8 Split Logic by Responsibility
BAD
- Validation
- Field updates
- Record creation
- Callouts All in one trigger.
GOOD Architecture
- AccountTriggerHandler
- AccountService
- AccountValidator
- AccountAsyncProcessor
- Easier testing
- No duplicate execution
Step 9 Prevent Cross-Object Trigger Loops
COMMON LOOP
- Account trigger updates Contact
Contact trigger updates Account
FIX Shared Static Guard
public class GlobalTriggerState {
public static Set<String> executed = new Set<String>();
}
if (GlobalTriggerState.executed.contains('Account')) return;
GlobalTriggerState.executed.add('Account');
- Prevents infinite cross-object loops
Step 10 Final Safe Trigger Template (Production-Ready)
trigger AccountTrigger on Account (before update, after insert) {
if (TriggerControl.isRunning) return;
TriggerControl.isRunning = true;
if (Trigger.isBefore && Trigger.isUpdate) {
for (Account acc : Trigger.new) {
if (acc.Status__c != 'Active') {
acc.Status__c = 'Active';
}
}
}
if (Trigger.isAfter && Trigger.isInsert) {
AccountService.createDefaultContacts(Trigger.new);
}
TriggerControl.isRunning = false;
}
Conclusion
In Salesforce Development, recursive triggers and data duplication are not platform bugs they are design issues. The fix is disciplined trigger architecture: one trigger per object, handler classes, recursion guards, before-trigger updates, and strict duplication checks. When triggers are treated as event routers instead of logic containers, recursion stops, data stays clean, and your org becomes stable and scalable.
Top comments (0)