How to Prevent Recursion in Apex Triggers in Salesforce Best Practices Explained

How to Prevent Recursion in Apex Triggers in salesforce: Best Practices Explained

, ,

Apex triggers can recurse when a trigger causes itself to execute repeatedly. This recursion potentially leads to infinite loops and hits Salesforce governor limits. This often happens when a trigger performs DML operations that invoke the same trigger again. To maintain system stability and data integrity, it’s crucial to implement strategies that prevent such recursive behavior.

This guide explores common methods to prevent recursion in Apex triggers, discussing their advantages, disadvantages, and practical use cases.

What is Trigger Recursion?

Trigger recursion occurs when a trigger causes additional DML operations, leading to repeated executions of the same trigger. This process can cause:

  • Governor Limit Exceptions: Such as exceeding the maximum trigger depth.
  • Performance Degradation: Due to repeated and unnecessary processing.

Some Example Scenarios of Trigger Recursion –

  1. Chained Triggers Across Objects:
    • Trigger A updates Object B. This update triggers logic on Object B. It updates Object C or loops back to Object A. This causes cascading invocations.
  2. Trigger ↔ Workflow Field Update:
    • A trigger updates a field. This activation triggers a workflow rule that updates the same record. This process re-triggers the original trigger.
  3. Apex Trigger ↔ Apex Trigger:
    • An after insert or after update trigger modifies same record. This causes the trigger to fire repeatedly. It continues until governor limits are hit or logic prevents further updates.

1. Using a Static Set to Track Processed Records

Concept:

Maintain a static Set of record IDs i.e. Set<Id> that have been processed. Before processing a record, check if its ID exists in the Set.

Code Example:
public class AccountTriggerHandler {
    private static Set<Id> processedRecordIds = new Set<Id>();
}
trigger AccountTrigger on Account (after update) {
    for (Account acc : Trigger.new) {
        if (!AccountTriggerHandler.processedRecordIds.contains(acc.Id)) {
            AccountTriggerHandler.processedRecordIds.add(acc.Id);
            // Your logic here
        }
    }
}
Benefits:
  • Prevents Recursion: Ensures the same record isn’t processed repeatedly in a single transaction, avoiding infinite loops.
  • Transaction Scope: Retains value during a transaction and resets automatically afterward.
  • Bulk-Safe: Suitable for bulk operations, efficiently tracking processed records.
  • Memory Efficiency: Stores only record IDs, reducing memory usage.
  • Simple Implementation: Easy to implement compared to alternatives.
Common Use Cases:
  1. One-Time Execution: Ensures specific logic runs only once per transaction.
  2. Cross-Context Sharing: Shares data between before and after trigger contexts.
  3. Duplicate Checking: Tracks processed records in complex logic.
Pros:
  • Simple to use and understand.
  • Avoids extra database operations or custom fields.
  • Resets automatically after each transaction, ensuring scope control.
  • Effective in bulk scenarios.
Cons:
  • Limited to a single transaction and cannot persist state across transactions.
  • Can make testing more complex if not properly managed or reset.
  • May obscure behavior, making code harder for others to follow.
  • Risk of heap space issues with large datasets.
  • Not suitable for complex scenarios involving multiple objects
  • Requires separate sets for different objects.
Example Scenario
  • Scenario: A trigger processes Contact records after update to update their Title field based on associated Account data. Also there is a workflow rule which updates the Title field of the Contact. It appends ” – Verified”. This is an example of recursion caused by the trigger and workflow field update. The Trigger ↔ Workflow Field Update: runs multiple times due to updates to the same object contact.
  • Implementation: Use a static Set<Id> to track processed Contact IDs to ensure no duplicate updates during the same transaction.
  • Why This Works: Offers record-level control and prevents duplicate processing within the same transaction for a single trigger context.

Trigger Code Example with Recursion –

Consider this below Contact trigger, which updates the title based on Account Name.

/**
* ContactTrigger
* —————-
* This trigger is executed after a Contact record is updated.
* The purpose is to update the Title of a Contact to include a reference to the related Account's Name.
*
* Key Features:
* – Identifies Contacts linked to an Account (via AccountId).
* – Queries the related Account's Name and updates the Contact's Title to reference it.
* – Ensures any updates made re-trigger workflows or additional triggers, if applicable.
*/
trigger ContactTrigger on Contact (after update) {
// Log a debug message indicating the trigger execution
System.debug('—- Contact Trigger run —-> After update');
// List to hold Contact records that need to be updated
List<Contact> contactsToUpdate = new List<Contact>();
// Loop through each Contact record in the trigger context
for (Contact con : Trigger.new) {
// Check if the Contact is linked to an Account (AccountId is not null)
if (con.AccountId != null) {
// Create a new instance of Contact to prepare for update
Contact conUpdate = new Contact();
conUpdate.Id = con.Id; // Set the Id of the Contact to update
System.debug('conUpdate.AccountId+++' + con.AccountId);
// Query to retrieve the Name of the related Account
Account acc = [SELECT Name FROM Account WHERE Id = :con.AccountId LIMIT 1];
// Update the Contact's Title to reference the Account's Name
conUpdate.Title = 'Related to ' + acc.Name;
// Add the prepared Contact to the list for bulk update
contactsToUpdate.add(conUpdate);
}
}
// If there are any Contacts to update, proceed with the update
if (!contactsToUpdate.isEmpty()) {
// This update will re-trigger the workflow
update contactsToUpdate;
}
}

To update an contact, simulate the recursion scenario via an anonymous window in the developer console. The trigger gets call multiple times and gets into recursion due to workflow update triggering updates again. This ultimately result in apex trigger depth error.

// Create a Contact
Contact contact = new Contact(FirstName = '1',
                              LastName = '1',
                              AccountId= '001Ws00003rvMGiIAM');
insert contact;

contact.LastName = 'Test';
update contact;
How it works :
  • When a Contact is updated, the trigger fires and updates the Title.
  • The update to Title causes the workflow rule to fire, which again updates the Title.
  • This workflow update, triggers the after update trigger again.
  • The process repeats, causing an infinite loop until Salesforce enforces a limit and throws the “maximum trigger depth exceeded” error.
Trigger Code Example with Recursion FIX using Set –

Now, this is the below Contact trigger, with the recursion fix using set.

/**
* ContactTrigger
*
* This trigger handles after-update operations on the Contact object.
* It delegates the processing logic to the `ContactTriggerHandler` class to ensure a clean separation of concerns.
*
* Trigger Context:
* – Executes in the after-update context.
*
* Key Features:
* – Invokes `handleAfterUpdate` in `ContactTriggerHandler` for processing updated contacts.
*/
trigger ContactTrigger on Contact (after update) {
// Check if the trigger is in the 'after update' context
if (Trigger.isAfter && Trigger.isUpdate) {
// Delegate processing to the handler class
ContactTriggerHandler.handleAfterUpdate(Trigger.new);
}
}
/**
* ContactTriggerHandler
*
* This handler class contains the logic for processing after-update operations on Contact records.
* It ensures efficient handling by tracking processed records to avoid recursive updates.
*
* Key Features:
* – Updates the Title of Contact records based on their related Account's Name.
* – Prevents redundant processing of Contact records using a `Set<Id>` to track processed records.
*
* Workflow:
* 1. Collects Account IDs from the updated Contact records.
* 2. Queries the Accounts based on the collected IDs.
* 3. Updates the Contact's Title with the associated Account's Name, if applicable.
*/
public class ContactTriggerHandler {
// A set to track processed Contact IDs to prevent recursive updates
private static Set<Id> processedContacts = new Set<Id>();
/**
* Processes Contact records after an update operation.
*
* @param contacts List of Contact records passed from the trigger.
*/
public static void handleAfterUpdate(List<Contact> contacts) {
System.debug('—- ContactTriggerHandler run —-> After Update');
// List to store Contact records that need updating
List<Contact> contactsToUpdate = new List<Contact>();
// Set to collect unique Account IDs associated with the Contacts
Set<Id> accountIds = new Set<Id>();
// Loop through the Contacts to gather Account IDs
for (Contact con : contacts) {
if (con.AccountId != null) {
// Add AccountId if it exists
accountIds.add(con.AccountId);
}
}
// Query the related Accounts using the collected IDs
Map<Id, Account> accountsByIds = new Map<Id, Account>(
[SELECT Name FROM Account WHERE Id IN :accountIds]
);
System.debug('Accounts fetched: ' + accountsByIds);
// Iterate through the Contacts to prepare updates
for (Contact con : contacts) {
// Check if the Contact has an associated Account and hasn't been processed
if (accountsByIds.containsKey(con.AccountId) && !processedContacts.contains(con.Id)) {
// Create a Contact record for update
Contact conUpdate = new Contact();
conUpdate.Id = con.Id;
// Update Title with Account Name
conUpdate.Title = 'Related to ' + accountsByIds.get(con.AccountId).Name;
// Add to update list
contactsToUpdate.add(conUpdate);
// Mark as processed
processedContacts.add(con.Id);
}
}
System.debug('Processed Contacts: ' + processedContacts);
System.debug('Contacts to Update: ' + contactsToUpdate);
// Perform update if there are Contacts to update
if (!contactsToUpdate.isEmpty()) {
// This update may re-trigger the workflow
update contactsToUpdate;
}
}
}

To update an contact, simulate the recursion scenario via an anonymous window in the developer console. The trigger gets call 3 times. 1 time due to an actual update in anonymous. 2 times due to a workflow field update. However, the actual logic of updating the title only runs once.

// Create a Contact
Contact contact = new Contact(FirstName = '1',
                              LastName = '1',
                              AccountId= '001Ws00003rvMGiIAM');
insert contact;

contact.LastName = 'Test';
update contact;
How it works:
  • When the trigger fires, it calls ContactTriggerHandler.handleAfterUpdate.
  • For each Contact, before updating, it checks if the Contact’s Id is in processedContacts.
  • If not, it updates the Contact and adds the Id to processedContacts.
  • This ensures each Contact is only processed once per transaction, breaking the recursion loop.

2. Using a Static Map of Trigger Event Keys

Concept:

For more complex scenarios, use a static Map to track additional information about processed records.

Code Example:
public class TriggerUtility {
    private static Map<String, Boolean> processedRecords = new Map<String, Boolean>();
	public static Boolean runCode(String key) {
        if (processedRecords.containsKey(key) && processedRecords.get(key)) {
            return false;
        } else {
            return true;
        }
    }
}
trigger AccountTrigger on Account (after update) {
	if (TriggerUtility.runCode('Account_AfterUpdate')) {
		// Your logic here
    }
}
Pros:
  • Allows storing additional data about each record.
  • Useful for complex processing logic.
Cons:
  • Increased complexity in implementation.
  • Higher memory usage compared to a Set.
  • Requires consistent and unique naming of event keys, which can lead to confusion in larger teams.
Example Scenario –
  • Scenario: You have a trigger on Opportunity. It processes records differently based on whether the trigger is running in before update or after insert. This is an example of recursion caused by the same trigger. The Apex Trigger ↔ Apex Trigger runs multiple times due to updates to the same object opportunity.
  • Implementation: Use a static Map<String, Boolean> where keys represent unique event types (e.g., Opportunity_BeforeUpdate, Opportunity_AfterInsert).
  • Why This Works: Enables precise control over logic execution for specific trigger contexts, preventing duplicate logic execution across multiple contexts.
Trigger Code Example with Recursion –

Consider this below Opportunity trigger, which updates the amount on insert and also updates the description on update.

//Opportunity Trigger With Recursion
/**
* OpportunityTrigger
*
* This trigger is responsible for handling "before update" and "after insert" events
* on the Opportunity object. It directly contains the logic for updating specific
* fields based on conditions, ensuring proper data updates during these events.
*
* Note: This trigger does not use a handler class or utility class for recursion control.
* Instead, it executes the logic inline, which might lead to recursion issues in certain scenarios.
*/
trigger OpportunityTrigger on Opportunity (before update, after insert) {
/**
* Handle "before update" event
*
* This block runs before an Opportunity record is updated.
* It checks if the Opportunity's StageName is 'Closed Won' and the Description is null.
* If both conditions are met, it sets the Description to a default value.
*/
if (Trigger.isBefore && Trigger.isUpdate) {
System.debug('—- Trigger run —-> Before Update');
for (Opportunity opp : Trigger.new) {
if (opp.StageName == 'Closed Won' && opp.Description == null) {
opp.Description = 'Automatically updated on Closed Won.';
}
}
}
/**
* Handle "after insert" event
*
* This block runs after new Opportunity records are inserted.
* It identifies records where the Amount field is null and updates them with a
* default Amount value of 1000. These updates are performed in bulk to minimize DML operations.
*/
if (Trigger.isAfter && Trigger.isInsert) {
List<Opportunity> oppsToUpdate = new List<Opportunity>();
System.debug('—- Trigger run —-> After Insert');
for (Opportunity opp : Trigger.new) {
if (opp.Amount == null) {
Opportunity updateOpp = new Opportunity();
updateOpp.Id = opp.Id;
updateOpp.Amount = 1000;
oppsToUpdate.add(updateOpp);
}
}
// Perform a bulk update to set the default Amount values
if (!oppsToUpdate.isEmpty()) {
update oppsToUpdate;
}
}
}

To create and update an opp, simulate the recursion scenario via an anonymous window in the developer console. The after insert action happens only once. The before update trigger fires twice.

// Create a new Opportunity
Opportunity opp = new Opportunity(Name = 'Test Opportunity', StageName = 'Prospecting', CloseDate = Date.today());
insert opp;

// Update the Opportunity to trigger both contexts
opp.StageName = 'Closed Won';
update opp;
How it works :
  • When an Opportunity is updated, the before update trigger fires. It checks if the StageName is ‘Closed Won’. Then, it verifies if the Description is null.
  • If both conditions are true, the trigger updates the Description field to ‘Automatically updated on Closed Won.’.
  • When an Opportunity is created, the after insert trigger fires and checks if the Amount field is null. If the Amount is null, the trigger updates it to a default value of 1000.
  • The update to the Amount field causes the before update trigger to execute again. This is because the update counts as a modification.
  • The before update logic runs and checks the StageName and Description conditions again, potentially updating the Description field.
  • This update to the Description field can further trigger the after insert logic. This happens if other conditions are met. It creates a cycle of trigger executions.
Trigger Code Example with Recursion FIX using Static Map –

Now, this is the below Opportunity trigger, with the recursion fix using static map.

// Opportunity Trigger with Recursion Fix
/**
* OpportunityTrigger
*
* This trigger is responsible for handling "before update" and "after insert" events
* on the Opportunity object. It delegates the logic to a handler class (OpportunityTriggerHandler)
* while utilizing a utility class (TriggerUtility) to prevent recursive execution.
*/
trigger OpportunityTrigger on Opportunity (before update, after insert) {
// Check if the trigger is executing before update event
if (Trigger.isBefore && Trigger.isUpdate) {
// Check if this part of the code hasn't already been run (to prevent recursion)
if (TriggerUtility.runCode('Opportunity_BeforeUpdate')) {
// Call the handler method for processing "before update" logic
OpportunityTriggerHandler.handleBeforeUpdate(Trigger.new);
}
}
// Check if the trigger is executing after insert event
if (Trigger.isAfter && Trigger.isInsert) {
// Check if this part of the code hasn't already been run (to prevent recursion)
if (TriggerUtility.runCode('Opportunity_AfterInsert')) {
// Call the handler method for processing "after insert" logic
OpportunityTriggerHandler.handleAfterInsert(Trigger.new);
}
}
}
/**
* OpportunityTriggerHandler
*
* This class contains the business logic for the Opportunity trigger.
* It defines methods to process "before update" and "after insert" events
* for the Opportunity object. These methods ensure that specific fields are
* updated or set based on certain conditions.
*/
public class OpportunityTriggerHandler {
/**
* handleBeforeUpdate
*
* This method processes the "before update" event for Opportunities.
* It ensures that when an Opportunity is moved to the "Closed Won" stage
* and the Description is null, the Description is automatically populated.
*
* @param opportunities List of Opportunity records to process.
* @return – N.A.
*/
public static void handleBeforeUpdate(List<Opportunity> opportunities) {
System.debug('—- Trigger run —-> Before Update');
// Iterate through each Opportunity in the list
for (Opportunity opp : opportunities) {
// Check if the Opportunity's stage is "Closed Won" and the description is empty
if (opp.StageName == 'Closed Won' && opp.Description == null) {
// Automatically set the description to a default value
opp.Description = 'Automatically updated on Closed Won.';
}
}
}
/**
* handleAfterInsert
*
* This method processes the "after insert" event for Opportunities.
* It ensures that any newly created Opportunity records with a null Amount
* field are updated with a default Amount value of 1000.
*
* @param – opportunities List of Opportunity records to process.
* @return – N.A.
*/
public static void handleAfterInsert(List<Opportunity> opportunities) {
System.debug('—- Trigger run —-> After Insert');
// List to collect Opportunities that need their Amount field updated
List<Opportunity> oppsToUpdate = new List<Opportunity>();
// Iterate through each Opportunity in the list
for (Opportunity opp : opportunities) {
// Check if the Amount field is empty (null)
if (opp.Amount == null) {
// Create a new Opportunity instance to update the record
Opportunity updateOpp = new Opportunity();
// Set the ID to the current Opportunity's ID
updateOpp.Id = opp.Id;
// Set the default Amount value
updateOpp.Amount = 1000;
// Add the updated Opportunity to the list
oppsToUpdate.add(updateOpp);
}
}
// Check if there are any Opportunities that need updating
if (!oppsToUpdate.isEmpty()) {
// Perform the update on all collected Opportunity records
update oppsToUpdate;
}
}
}
/**
* TriggerUtility
*
* This utility class provides a mechanism to prevent recursive execution
* of trigger logic. It uses a map to track whether a specific section of
* code has already been executed, ensuring that triggers do not execute
* repeatedly for the same record.
*/
public class TriggerUtility {
// A map to track whether a specific key's code has been executed
private static Map<String, Boolean> processedRecords = new Map<String, Boolean>();
/**
* runCode
*
* This method determines whether a block of code associated with a unique
* key should execute. It ensures that the same block of code does not run
* multiple times during a single transaction.
*
* @param – key Unique identifier for the code block.
* @return – Boolean indicating if the code should run (true) or not (false).
*/
public static Boolean runCode(String key) {
// Check if the map already contains the key and if its value is true (code already executed)
if (processedRecords.containsKey(key) && processedRecords.get(key)) {
// Return false to indicate that the code should not run again
return false;
} else {
// Mark the key as processed by adding/updating the map with the value true
processedRecords.put(key, true);
// Return true to indicate that the code should run
return true;
}
}
}

To create and update an opp, simulate the recursion scenario via an anonymous window in the developer console. The after insert action happens only once. The before update trigger also fires only once.

// Create a new Opportunity
Opportunity opp = new Opportunity(Name = 'Test Opportunity', StageName = 'Prospecting', CloseDate = Date.today());
insert opp;

// Update the Opportunity to trigger both contexts
opp.StageName = 'Closed Won';
update opp;
How it works :
  • When an Opportunity is updated, the before update trigger fires.
    • It checks if the StageName is ‘Closed Won’ and the Description is null.
    • If true, it updates the Description to ‘Automatically updated on Closed Won.’.
  • When an Opportunity is created, the after insert trigger fires.
    • It checks if the Amount is null.
    • If true, it updates the Amount to 1000.
  • The update to the Amount field triggers the before update logic again, as the update is considered a modification.
  • This process can repeat. It causes the trigger to fire multiple times. This might cause recursion. The TriggerUtility prevents it by ensuring each trigger section runs only once per transaction.

3. Best Method Using Centralized Static Map with Trigger Operation Type

Concept:

Leverage Salesforce’s trigger context variables, such as Trigger.isInsert, Trigger.isUpdate, and Trigger.isDelete, to control the execution flow within triggers.

Code Example:
public class TriggerUtility {
	public static Map<TriggerOperation, Set<Id>> processedIdsByContext = new Map<TriggerOperation, Set<Id>>();
	// Checks if a record has already been processed for the current trigger operation
    public static Boolean shouldProcess(TriggerOperation op, Id recordId) {
		if (!processedIdsByContext.containsKey(op)) {
			// Create a new set for the operation
            processedIdsByContext.put(op, new Set<Id>());
        }
        Set<Id> processedIds = processedIdsByContext.get(op);
        if (processedIds.contains(recordId)) {
            return false;
        }
        processedIds.add(recordId);
        return true;
	}
}
trigger CaseTrigger on Account (before update) {
    if (Trigger.isUpdate) {
        for (Case c : Trigger.new) {
        if (TriggerUtility.shouldProcess(Trigger.operationType, c.Id)) {
			// Your logic here
		}
    }
}
Pros:
  • Helps in controlling trigger execution flow.
Cons:
  • Does not inherently prevent recursion; should be combined with other methods.
  • Slightly more complex to implement and requires proper integration into all relevant triggers.

Use Case: Bulk Operations and Cross-Trigger Contexts

  • Example Scenario: A case trigger updates Case records. It also updates their associated Contact records. Additionally, a contact trigger updates all its associated cases. This is an example of recursion caused by chained triggers across objects using Case and Contact.Multiple triggers might modify the same record within a single transaction.
  • Implementation: Utilize a centralized utility class, such as TriggerUtility. It incorporates a Map<TriggerOperation, Set<Id>> to track processed records. This is based on trigger operation types.
  • Why This Works: Offers robust handling of recursion across different contexts and supports bulk operations. Ensures the same record is not processed multiple times within the same transaction.
Trigger Code Example with Recursion –
//Case Trigger With Recursion
/**
* CaseTrigger
*
* This trigger is executed after the update event on the Case object. Its primary purpose
* is to check if any Case record has been escalated (Status = 'Escalated') and update the
* associated Contact's Title to 'Manager' if it is not already set to 'Manager'.
*
* Note: This implementation directly queries and updates the Contact records.
* Care should be taken to ensure compliance with Salesforce Governor Limits,
* especially in scenarios involving bulk operations.
*/
trigger CaseTrigger on Case (after update) {
System.debug('—- Case Trigger run —-> After Update');
// List to store Contact records that need updates
List<Contact> contactsToUpdate = new List<Contact>();
/**
* Iterate through all updated Case records
*
* – If a Case's Status is 'Escalated':
* – Query the associated Contact record (if available).
* – Check if the Contact's Title is not 'Manager'.
* – If the Title needs to be updated, add the Contact record to the update list.
*/
for (Case c : Trigger.new) {
if (c.Status == 'Escalated') {
// Query the associated Contact using the ContactId field on the Case
Contact associatedContact = [SELECT Id, Title FROM Contact WHERE Id = :c.ContactId LIMIT 1];
// Ensure the Contact exists and its Title is not already 'Manager'
if (associatedContact != null && associatedContact.Title != 'Manager') {
associatedContact.Title = 'Manager'; // Update the Title to 'Manager'
contactsToUpdate.add(associatedContact); // Add to the update list
}
}
}
/**
* Perform a bulk update on all identified Contact records
*
* This operation ensures minimal DML usage, and the update will trigger
* any Contact-specific triggers if defined (e.g., `ContactTrigger`).
*/
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate; // Perform the update operation
}
}
//Contact Trigger With Recursion
/**
* ContactTrigger
*
* This trigger is executed after the update event on the Contact object. Its purpose is to:
* – Check if a Contact's Title has been updated to 'Manager'.
* – Identify related Case records linked to that Contact.
* – If the Case is not already closed, update its Status to 'Closed'.
*
* Note: This implementation queries Case records and updates them. Care must be taken to
* manage Governor Limits, especially in bulk processing scenarios.
*/
trigger ContactTrigger on Contact (after update) {
System.debug('—- Contact Trigger run —-> After Update');
// List to store Case records that require status updates
List<Case> casesToUpdate = new List<Case>();
/**
* Iterate through all updated Contact records
*
* – If a Contact's Title is set to 'Manager':
* – Query the related Case using the ContactId field.
* – Check if the Case's Status is not 'Closed'.
* – If the Case requires a status update, add it to the update list.
*/
for (Contact con : Trigger.new) {
if (con.Title == 'Manager') {
// Query the related Case using the ContactId field
Case relatedCase = [SELECT Id, Status FROM Case WHERE ContactId = :con.Id LIMIT 1];
// Ensure the Case exists and its Status is not 'Closed'
if (relatedCase != null && relatedCase.Status != 'Closed') {
relatedCase.Status = 'Closed'; // Update the Status to 'Closed'
casesToUpdate.add(relatedCase); // Add to the update list
}
}
}
/**
* Perform a bulk update on all identified Case records
*
* This operation ensures efficient DML usage and triggers any Case-specific triggers
* (e.g., `CaseTrigger`) defined in the system.
*/
if (!casesToUpdate.isEmpty()) {
// Perform the update operation
update casesToUpdate;
}
}

To create a contact, simulate the recursion fix scenario via an anonymous window in the developer console. Then, create and update a case. The after update case action happens twice. The after update contact trigger action fires once.

// Create a Contact
Contact contact = new Contact(FirstName = 'John', LastName = 'Peter');
insert contact;

// Create a Case associated with the Contact
Case caseRec = new Case(ContactId = contact.Id, Status = 'New');
insert caseRec;

// Update the Case to trigger the chain
caseRec.Status = 'Escalated';
update caseRec;
Trigger Code Example with Recursion FIX using Static Map with Trigger Operation Type –

Now, this is the below Case and Contact trigger, with the recursion fix using static map with trigger operation type.

//Case Trigger Without Recursion
/**
* CaseTrigger
*
* This trigger is executed after the update event on the Case object. Its purpose is to:
* – Monitor changes to the Case's Status field.
* – If the Status changes to 'Escalated':
* – Retrieve the associated Contact record linked to the Case.
* – If the Contact's Title is not already 'Manager', update it to 'Manager'.
*
* The trigger includes a mechanism to prevent recursive operations using `TriggerUtility`.
*
* Note: Ensure SOQL and DML operations are optimized to handle bulk scenarios and avoid Governor Limit violations.
*/
trigger CaseTrigger on Case (after update) {
System.debug('—- Case Trigger run —-> After Update');
// List to store Contact records requiring updates
List<Contact> contactsToUpdate = new List<Contact>();
/**
* Iterate through updated Case records
*
* – Use `TriggerUtility` to prevent recursive processing.
* – Check if the Status has been updated to 'Escalated'.
* – Retrieve the associated Contact and validate its Title.
* – If necessary, prepare the Contact for an update.
*/
for (Case c : Trigger.new) {
// Ensure processing is required for this Case (avoiding recursion)
if (TriggerUtility.shouldProcess(Trigger.operationType, c.Id)) {
// Process only if Status changes to 'Escalated'
if (c.Status == 'Escalated' && !Trigger.oldMap.get(c.Id).Status.equals('Escalated')) {
// Query the associated Contact record
Contact associatedContact = [SELECT Id, Title FROM Contact WHERE Id = :c.ContactId LIMIT 1];
// Validate and prepare Contact for an update
if (associatedContact != null && associatedContact.Title != 'Manager') {
associatedContact.Title = 'Manager'; // Set Title to 'Manager'
contactsToUpdate.add(associatedContact); // Add to the update list
}
}
}
}
/**
* Perform bulk updates for all identified Contacts
*
* This operation ensures efficient DML usage and triggers any associated triggers on the Contact object.
*/
if (!contactsToUpdate.isEmpty()) {
// Execute the update operation
update contactsToUpdate;
}
}
//Contact Trigger Without Recursion
/**
* ContactTrigger
*
* This trigger is executed after the update event on the Contact object. Its purpose is to:
* – Monitor changes to the Contact's Title field.
* – If the Title changes to 'Manager':
* – Retrieve the related Case record associated with the Contact.
* – If the Case's Status is not 'Closed', update it to 'Closed'.
*
* The trigger employs `TriggerUtility` to prevent recursive operations.
*
* Note: Designed to handle bulk processing efficiently and avoid Governor Limit violations.
*/
trigger ContactTrigger on Contact (after update) {
System.debug('—- Contact Trigger run —-> After Update');
// List to store Case records requiring updates
List<Case> casesToUpdate = new List<Case>();
/**
* Iterate through updated Contact records
*
* – Use `TriggerUtility` to avoid recursive updates.
* – Check if the Title field has been updated to 'Manager'.
* – Retrieve the related Case record and validate its Status.
* – If necessary, prepare the Case for an update.
*/
for (Contact con : Trigger.new) {
// Ensure processing is required for this Contact (avoiding recursion)
if (TriggerUtility.shouldProcess(Trigger.operationType, con.Id)) {
// Process only if Title changes to 'Manager'
if (con.Title == 'Manager' && Trigger.oldMap.get(con.Id).Title != null && !Trigger.oldMap.get(con.Id).Title.equals('Manager')) {
// Query the related Case record
Case relatedCase = [SELECT Id, Status FROM Case WHERE ContactId = :con.Id LIMIT 1];
// Validate and prepare Case for an update
if (relatedCase != null && relatedCase.Status != 'Closed') {
relatedCase.Status = 'Closed'; // Set Status to 'Closed'
casesToUpdate.add(relatedCase); // Add to the update list
}
}
}
}
/**
* Perform bulk updates for all identified Cases
*
* This ensures efficient DML usage and triggers any associated triggers on the Case object.
*/
if (!casesToUpdate.isEmpty()) {
// Execute the update operation
update casesToUpdate;
}
}
/**
* TriggerUtility
*
* This utility class manages recursive operations in triggers.
* Its purpose is to:
* – Track record processing by trigger operation to prevent duplicate actions.
* – Provide a mechanism to check if a record has already been processed during the current transaction context.
*
* Key Features:
* – Ensures efficient handling of recursive triggers.
* – Helps maintain data integrity by preventing multiple updates to the same record within the same context.
*
* Usage:
* – Call `shouldProcess(Trigger.operationType, recordId)` to determine if a record should be processed.
*/
public class TriggerUtility {
/**
* A map that maintains a set of processed record IDs for each trigger operation.
* – Key: `TriggerOperation` (e.g., INSERT, UPDATE).
* – Value: A set of record IDs that have been processed for the corresponding operation.
*/
private static Map<TriggerOperation, Set<Id>> processedIdsByContext = new Map<TriggerOperation, Set<Id>>();
/**
* Checks whether a record has already been processed for the current trigger operation.
*
* @param op The current trigger operation (e.g., INSERT, UPDATE).
* @param recordId The unique ID of the record being processed.
* @return Boolean True if the record has not yet been processed; False otherwise.
*/
public static Boolean shouldProcess(TriggerOperation op, Id recordId) {
// Initialize the set for the trigger operation if it doesn't exist
if (!processedIdsByContext.containsKey(op)) {
// Create a new set for the operation
processedIdsByContext.put(op, new Set<Id>());
}
// Retrieve the set of processed IDs for the current operation
Set<Id> processedIds = processedIdsByContext.get(op);
// Check if the record ID is already in the processed set
if (processedIds.contains(recordId)) {
// Record already processed, skip further action
return false;
}
// Add the record ID to the processed set
processedIds.add(recordId);
// Record is ready for processing
return true;
}
}

To create a contact, simulate the recursion fix scenario in an anonymous window. Use the developer console to create and update a case. The after update case action only runs once. The after update contact trigger action fires once.

How This Works
  1. TriggerUtility:
    • A static map stores processed record IDs for each Trigger.operationType (e.g., BEFORE_UPDATE, AFTER_INSERT).
    • Prevents redundant logic execution by ensuring that each record is processed only once per operation.
  2. Case and Contact Triggers:
    • Before executing the logic, TriggerUtility.shouldProcess() checks if the record has already been processed for the current operation.
  3. Chained Execution Flow:
    • Case trigger updates the related contact when the case status changes.
    • Contact trigger updates the related case when the contact title changes.
  4. Prevention of Recursion:
    • Without the TriggerUtility, updates in one trigger could recursively invoke the other indefinitely.
    • Using the utility class, each record is processed only once, breaking the recursion loop.

Comparison of Techniques

MethodRecord-Level ControlTrigger Context AwarenessBulk-SafeReusable Across Triggers
Static Boolean Variable❌ No❌ No❌ No❌ No
Static Set✅ Yes❌ No❌ No❌ No
Static Map with Event Key❌ No✅ Yes✅ Yes✅ Yes
Centralized Static Map✅ Yes✅ Yes✅ Yes✅ Yes

Conclusion

There are multiple ways to avoid recursion in Apex triggers. Using a Centralized Static Map with Trigger Operation Type is the most reliable and scalable approach. It addresses the limitations of other methods and ensures efficient, context-aware, and reusable trigger logic. By adhering to best practices, you can create robust Apex triggers. Selecting the right strategy makes them efficient and tailored to your organization’s needs.

2 responses to “How to Prevent Recursion in Apex Triggers in salesforce: Best Practices Explained”

  1. […] variables like set and maps to control the number of times the trigger is executed. Refer my blog How to Prevent Recursion in Apex Triggers In Salesforce: Best Practices Explained for more […]

    Like

  2. Thank you again for your clear and concise explanation of using static Sets and Maps to prevent recursion in triggers.

    While reading I got one question not sure it is genuine or not —does this method also work if the trigger calls a future method or queueable class? Since they run separately, will the static variable still stop recursion?

    Like

Leave a comment