DEV Community

Selavina B
Selavina B

Posted on

Trigger Logic Causing Recursive Updates or Data Duplication

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 insert
  • after insert
  • before update
  • after 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
}

Enter fullscreen mode Exit fullscreen mode

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
    );
}

Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode
  • Prevents recursive execution
  • Centralized control

Step 5 Only Update Records When Data Actually Changes
BAD

acc.Status__c = 'Active';
Enter fullscreen mode Exit fullscreen mode

Runs every time → recursion.

GOOD

if (acc.Status__c != 'Active') {
    acc.Status__c = 'Active';
}
Enter fullscreen mode Exit fullscreen mode
  • 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
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
  • No duplicates
  • Bulk-safe

Step 7 Use before Triggers Instead of after When Possible
BAD

after update → update same record
Enter fullscreen mode Exit fullscreen mode

GOOD

before update → modify Trigger.new

Enter fullscreen mode Exit fullscreen mode
if (acc.Score__c == null) {
    acc.Score__c = 0;
}

Enter fullscreen mode Exit fullscreen mode
  • 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>();
}
Enter fullscreen mode Exit fullscreen mode
if (GlobalTriggerState.executed.contains('Account')) return;
GlobalTriggerState.executed.add('Account');
Enter fullscreen mode Exit fullscreen mode
  • 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;
}

Enter fullscreen mode Exit fullscreen mode

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)