Here’s the complete fix addressing all three focus areas - trigger recursion prevention, stage change detection, and bulk update handling:
1. TRIGGER RECURSION PREVENTION - Add static set to track processed records:
public class TriggerHelper {
private static Set<Id> processedOpps = new Set<Id>();
public static Boolean isProcessed(Id oppId) {
return processedOpps.contains(oppId);
}
public static void setProcessed(Id oppId) {
processedOpps.add(oppId);
}
}
This static set persists for the entire transaction, preventing the same opportunity from being processed multiple times even if the trigger fires again.
2. STAGE CHANGE DETECTION - Compare old vs new values:
trigger OpportunityStageAlert on Opportunity (after update) {
List<Id> oppsToNotify = new List<Id>();
for(Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
// Check if stage CHANGED to Closed Won
if(opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won' &&
!TriggerHelper.isProcessed(opp.Id)) {
oppsToNotify.add(opp.Id);
TriggerHelper.setProcessed(opp.Id);
}
}
}
This ensures emails only send when stage transitions TO Closed Won, not on subsequent updates to already-closed opportunities.
3. BULK UPDATE HANDLING - Process all records in single operation:
Your original code called EmailHelper.sendAlert() inside the loop, making individual callouts/operations. For 50 opportunities, that’s 50 separate operations. Instead:
// After the loop, send all at once
if(!oppsToNotify.isEmpty()) {
EmailHelper.sendBulkAlerts(oppsToNotify);
}
Implement sendBulkAlerts() to handle all notifications in one operation. This is critical for governor limits and performance.
Additional safeguards:
- Move any DML operations out of the email helper to prevent trigger re-entry
- If you need to update the opportunity after sending email, use an @future method or Platform Event to break the transaction chain
- Add trigger framework to centralize recursion control across all triggers
- Consider using a trigger handler pattern for better testability
Testing bulk scenarios:
// Pseudocode - Test class approach:
1. Create 200 test opportunities in various stages
2. Update all to Closed Won in single DML statement
3. Assert exactly 200 emails queued (not 400 or 600)
4. Verify no SOQL/DML governor limit exceptions
5. Update same opportunities again (change Amount)
6. Assert NO additional emails sent
This comprehensive approach eliminates duplicates while maintaining bulk operation efficiency. The key insight is that trigger recursion happens when you don’t track what you’ve already processed AND when your helper methods cause additional DML that re-fires the trigger. Breaking both cycles solves the problem permanently.
This draft is based on general Salesforce knowledge. It has not been verified against your specific version and environment. Practitioners: verify the steps and share your experience below.