Problem statement
Poorly designed Apex triggers run multiple times per transaction, causing recursive updates, duplicate records, or unintended field changes because of missing recursion guards, mixed responsibilities, or lack of a trigger framework.
Step 1 Understand WHY recursion happens
Triggers can fire multiple times in a single transaction:
before insert
after insert
before update
after update
Common causes of recursion
- Trigger updates the same object again
- Trigger updates a related object that triggers back
- Workflow / Flow / Process Builder updates fields → re-fires trigger
- No guard to stop re-execution
Example of bad trigger logic
trigger AccountTrigger on Account (after update) {
for (Account acc : Trigger.new) {
acc.Description = 'Updated'; // causes recursion
}
update Trigger.new;
}
This will loop until governor limits are hit.
Step 2 Use ONE trigger per object (no logic inside)
Correct trigger structure
Triggers should only:
- Detect context
- Call a handler
trigger AccountTrigger on Account (
before insert, before update,
after insert, after update
) {
AccountTriggerHandler.run(
Trigger.new,
Trigger.oldMap,
Trigger.isInsert,
Trigger.isUpdate,
Trigger.isBefore,
Trigger.isAfter
);
}
No logic. No loops. No DML here.
Step 3 Add a recursion guard (MANDATORY)
Static Boolean Guard Pattern (most reliable)
public class TriggerGuard {
private static Set<String> executed = new Set<String>();
public static Boolean shouldRun(String key) {
if (executed.contains(key)) {
return false;
}
executed.add(key);
return true;
}
}
This guard works per transaction.
Step 4 Implement a proper Trigger Handler
public with sharing class AccountTriggerHandler {
public static void run(
List<Account> newList,
Map<Id, Account> oldMap,
Boolean isInsert,
Boolean isUpdate,
Boolean isBefore,
Boolean isAfter
) {
if (isBefore && isInsert) {
beforeInsert(newList);
}
if (isBefore && isUpdate) {
beforeUpdate(newList, oldMap);
}
if (isAfter && isInsert) {
afterInsert(newList);
}
if (isAfter && isUpdate) {
afterUpdate(newList, oldMap);
}
}
// ------------------ BEFORE INSERT ------------------
private static void beforeInsert(List<Account> records) {
for (Account acc : records) {
if (acc.Description == null) {
acc.Description = 'Created';
}
}
}
// ------------------ BEFORE UPDATE ------------------
private static void beforeUpdate(List<Account> records, Map<Id, Account> oldMap) {
for (Account acc : records) {
Account oldAcc = oldMap.get(acc.Id);
if (acc.Name != oldAcc.Name) {
acc.Description = 'Name Changed';
}
}
}
// ------------------ AFTER INSERT ------------------
private static void afterInsert(List<Account> records) {
if (!TriggerGuard.shouldRun('AccountAfterInsert')) return;
List<Contact> contacts = new List<Contact>();
for (Account acc : records) {
contacts.add(new Contact(
LastName = 'Primary',
AccountId = acc.Id
));
}
insert contacts; // safe
}
// ------------------ AFTER UPDATE ------------------
private static void afterUpdate(List<Account> records, Map<Id, Account> oldMap) {
if (!TriggerGuard.shouldRun('AccountAfterUpdate')) return;
List<Account> toUpdate = new List<Account>();
for (Account acc : records) {
Account oldAcc = oldMap.get(acc.Id);
// Only update if field actually changed
if (acc.AnnualRevenue != oldAcc.AnnualRevenue) {
toUpdate.add(new Account(
Id = acc.Id,
Rating = 'Hot'
));
}
}
if (!toUpdate.isEmpty()) {
update toUpdate; // guarded → no recursion
}
}
}
Step 5 Prevent duplicate records (idempotency check)
Bad (creates duplicates)
insert new Contact(AccountId = acc.Id, LastName = 'Primary');
Good (check before insert)
Set<Id> accountIds = new Set<Id>();
for (Account acc : records) {
accountIds.add(acc.Id);
}
Map<Id, Contact> existing = new Map<Id, Contact>();
for (Contact c : [
SELECT Id, AccountId
FROM Contact
WHERE AccountId IN :accountIds
AND LastName = 'Primary'
]) {
existing.put(c.AccountId, c);
}
List<Contact> toInsert = new List<Contact>();
for (Account acc : records) {
if (!existing.containsKey(acc.Id)) {
toInsert.add(new Contact(
LastName = 'Primary',
AccountId = acc.Id
));
}
}
insert toInsert;
Step 6 Never do DML in BEFORE triggers (rule)
Context Allowed
before insert/update DML
after insert/update DML
before modify Trigger.new
after separate update list
Step 7 Handle Flow / Process Builder recursion
Flows often cause recursion without you realizing it.
Safe pattern:
Triggers handle core data logic
Flows handle UI / notifications
Detection:
If recursion still occurs:
- Check Record-Triggered Flows
- Ensure Flow entry criteria prevents re-firing
Example Flow criteria:
Run only when:
AnnualRevenue IS CHANGED = TRUE
Step 8 Add tests that prove recursion is fixed
@IsTest
private class AccountTriggerTest {
@IsTest
static void testNoRecursion() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
acc.AnnualRevenue = 500000;
update acc;
Test.stopTest();
Account updated = [
SELECT Rating, Description
FROM Account
WHERE Id = :acc.Id
];
System.assertEquals('Hot', updated.Rating);
System.assertNotEquals(null, updated.Description);
}
}
If recursion exists, this test will fail or hit limits.
Step 9 Golden rules to prevent recursion forever
- One trigger per object
- Zero business logic in triggers
- Use handler classes
- Always compare
Trigger.newvsTrigger.old - Never update records blindly
- Use static guards for after triggers
- Bulk-safe logic only
Conclusion
Trigger recursion and data duplication happen when Apex triggers lack clear separation of responsibilities, field-change checks, and recursion guards. By adopting a single-trigger + handler framework, using static execution guards, performing bulk-safe DML, and ensuring updates happen only when data truly changes, you can completely eliminate infinite loops, duplicate records, and unpredictable behavior. Well structured triggers are not just safer they’re easier to test, scale, and maintain in production.
Top comments (0)