DEV Community

Selavina B
Selavina B

Posted on

Trigger Logic Causing Recursive Updates or Data Duplication Step-by-Step Fix (with Code)

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

Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

Enter fullscreen mode Exit fullscreen mode

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.new vs Trigger.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)