mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
* Migrating Core Salesforce Skills * Updating pr comments * updat reference * Updating a skill * Migrating Datacloud skills * Migrating Industries cloud skills * Validating - skills fixing --------- Co-authored-by: Sandip Kumar Yadav <sandipkumar.yadav+sfemu@salesforce.com>
11 KiB
11 KiB
Trigger Deployment Safety Guide
Overview
This guide covers deployment considerations for triggers, focusing on cascade failures, atomicity patterns, and async decoupling strategies.
1. Understanding Cascade Failures
What Are Cascade Failures?
When an exception in one trigger causes rollback of operations from preceding triggers in the chain.
Account Insert → AccountTrigger → ContactTrigger → OpportunityTrigger
✓ ✓ ✗ (Exception)
All three operations ROLL BACK
Default Behavior
- Uncaught exceptions in triggers cause rollback of all operations in the transaction
- This includes operations from other triggers that fired successfully
- This behavior may or may not be desired depending on business requirements
When Cascade Failure is DESIRED
- All operations represent ONE atomic business process
- Example: Order with line items must be complete or not exist
- Same business unit/persona owns all the data
// DESIRED cascade: Order and OrderItems must both succeed
trigger OrderTrigger on Order__c (after insert) {
// If this fails, we WANT the Order insert to roll back
List<OrderItem__c> items = createDefaultLineItems(Trigger.new);
insert items; // Exception here rolls back Order insert - GOOD
}
When Cascade Failure is PROBLEMATIC
- Operations represent INDEPENDENT business processes
- Example: Creating account + syncing to external CRM
- Different business units own different parts of the process
// PROBLEMATIC cascade: CRM sync failure shouldn't prevent Account creation
trigger AccountTrigger on Account (after insert) {
// If CRM sync fails, Account creation also fails - BAD
CRMService.syncNewAccounts(Trigger.new); // Exception here rolls back Account
}
2. Atomicity Patterns
Explicit Savepoints
Use Database.setSavepoint() for explicit transaction control within a single process.
/**
* Demonstrates explicit atomicity control for cross-object operations
*/
public class AccountOwnership {
/**
* Reassigns all related records when account owner changes.
* This is an ATOMIC operation - all changes succeed or all roll back.
*/
public static void reassignRelatedRecords(
List<Account> accountsWithNewOwner,
Map<Id, Account> oldAccountsById
) {
// Create savepoint for atomic operation
Savepoint sp = Database.setSavepoint();
try {
Map<Id, Id> newOwnerByAccountId = buildOwnerMap(accountsWithNewOwner);
// Step 1: Reassign opportunities
reassignOpportunities(newOwnerByAccountId);
// Step 2: Reassign cases
reassignCases(newOwnerByAccountId);
// Step 3: Create transition tasks
createOwnerTransitionTasks(accountsWithNewOwner, oldAccountsById);
} catch (Exception e) {
// Rollback ALL changes if ANY step fails
Database.rollback(sp);
// Log the failure for investigation
ErrorLogger.log('AccountOwnership.reassignRelatedRecords', e);
// Re-throw to inform the caller
throw new AccountOwnershipException(
'Failed to reassign related records: ' + e.getMessage()
);
}
}
}
When to Use Savepoints
| Scenario | Use Savepoint? | Reason |
|---|---|---|
| Multi-object update in same trigger | Yes | Ensures consistency |
| Order + OrderItems creation | Yes | Business atomicity required |
| Account + CRM sync | No | Use async decoupling instead |
| Validation that spans objects | Yes | All-or-nothing validation |
3. Async Decoupling Patterns
The Problem
After-trigger logic that represents an independent business process can cause cascade failures.
Solution: Queueable Jobs
/**
* Queueable job for processing that should not block the main transaction
*/
public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
private Set<Id> accountIds;
private TriggerOperation operationType;
public AccountSyncQueueable(Set<Id> accountIds, TriggerOperation operationType) {
this.accountIds = accountIds;
this.operationType = operationType;
}
public void execute(QueueableContext context) {
try {
List<Account> accounts = [
SELECT Id, Name, Website, Industry, BillingAddress
FROM Account
WHERE Id IN :accountIds
];
switch on operationType {
when AFTER_INSERT {
ExternalCRM.createAccounts(accounts);
}
when AFTER_UPDATE {
ExternalCRM.updateAccounts(accounts);
}
when AFTER_DELETE {
ExternalCRM.deleteAccounts(accountIds);
}
}
} catch (Exception e) {
// Log error but don't fail - this is independent of main transaction
ErrorLogger.log('AccountSyncQueueable', e, accountIds);
}
}
}
Usage in Trigger Handler
public void afterInsert(List<Account> newAccounts, Map<Id, Account> newAccountsById) {
// Synchronous operations that SHOULD block
AccountNotifications.notifySalesTeam(newAccounts);
// Async operations that should NOT block main transaction
if (canEnqueueJob()) {
System.enqueueJob(new AccountSyncQueueable(
newAccountsById.keySet(),
TriggerOperation.AFTER_INSERT
));
}
}
private Boolean canEnqueueJob() {
return !System.isBatch() &&
!System.isFuture() &&
Limits.getQueueableJobs() < Limits.getLimitQueueableJobs();
}
Platform Events for Maximum Decoupling
// Publisher (in trigger)
EventBus.publish(new Account_Changed__e(
Account_Id__c = account.Id,
Change_Type__c = 'INSERT'
));
// Subscriber (separate trigger on platform event)
trigger AccountChangedSubscriber on Account_Changed__e (after insert) {
// Process events - failures here don't affect original transaction
List<Id> accountIds = new List<Id>();
for (Account_Changed__e event : Trigger.new) {
accountIds.add(event.Account_Id__c);
}
// Sync to external system
}
Decoupling Decision Matrix
| Business Process | Same User Persona? | Failure Impact | Recommendation |
|---|---|---|---|
| Order + Line Items | Yes | Must be atomic | Synchronous + Savepoint |
| Account + Audit Log | Yes | Can retry | Queueable |
| Account + External CRM | No | Independent | Platform Event |
| Contact + Marketing Cloud | No | Independent | Platform Event |
| Record + Email Notification | Yes | Non-critical | @future |
4. Pre-Deployment Checklist
Trigger Cascade Analysis
Before deploying triggers, analyze the cascade impact:
CHECKLIST:
□ List all triggers that fire on the same transaction
□ Identify which operations are atomic (same business process)
□ Identify which operations are independent (different processes)
□ Verify exception handling in each trigger
□ Check for recursive trigger prevention
□ Review async job usage and limits
Questions to Answer
-
What triggers will fire together?
- Map the full trigger chain for each DML operation
- Include triggers on related objects (parent-child)
-
What happens if each trigger fails?
- Which operations will roll back?
- Is that the desired behavior?
-
Are there external system calls?
- Should they be async?
- What happens if they fail?
-
What about governor limits?
- How many DML statements in the chain?
- How many SOQL queries?
- Can bulk operations stay within limits?
5. Testing Cascade Scenarios
Test Cascade Success
@IsTest
static void testTriggerChain_AllSucceed() {
Test.startTest();
Account acc = new Account(Name = 'Test');
insert acc; // Should fire AccountTrigger, create child records, etc.
Test.stopTest();
// Verify all expected records were created
Assert.areEqual(1, [SELECT COUNT() FROM Contact WHERE AccountId = :acc.Id]);
Assert.areEqual(1, [SELECT COUNT() FROM Task WHERE WhatId = :acc.Id]);
}
Test Cascade Failure (Expected Rollback)
@IsTest
static void testTriggerChain_ChildFailure_RollsBack() {
// Configure scenario that will cause child trigger to fail
Test.startTest();
try {
Account acc = new Account(Name = 'Trigger Failure Test');
insert acc;
Assert.fail('Expected DmlException');
} catch (DmlException e) {
// Expected - verify Account was NOT created
Assert.areEqual(0, [SELECT COUNT() FROM Account WHERE Name = 'Trigger Failure Test']);
}
Test.stopTest();
}
Test Async Decoupling
@IsTest
static void testAccountInsert_CRMSyncAsync_DoesNotBlock() {
// Even if CRM sync would fail, Account should be created
Test.startTest();
Account acc = new Account(Name = 'Async Test');
insert acc;
Test.stopTest();
// Account should exist regardless of async job outcome
Assert.areEqual(1, [SELECT COUNT() FROM Account WHERE Name = 'Async Test']);
// Verify async job was enqueued
Assert.areEqual(1, [SELECT COUNT() FROM AsyncApexJob WHERE JobType = 'Queueable']);
}
6. Deployment Commands for Trigger Safety
Validate Before Deploying
# Always validate triggers before deploying
sf project deploy start --dry-run \
--source-dir force-app/main/default/triggers \
--source-dir force-app/main/default/classes \
--test-level RunLocalTests \
--target-org alias
Deploy with Test Coverage
# Deploy triggers with their test classes
sf project deploy start \
--source-dir force-app/main/default/triggers \
--source-dir force-app/main/default/classes \
--test-level RunSpecifiedTests \
--tests AccountTriggerTest,ContactTriggerTest \
--target-org alias
Verify Trigger Order
After deployment, verify trigger execution order if you have multiple triggers on the same object:
# Query trigger execution order (via Debug Logs)
sf apex tail log --target-org alias
# Or check in Setup: Object Manager → [Object] → Triggers
Summary
| Pattern | Use Case | Trade-off |
|---|---|---|
| Default (Cascade) | Atomic business processes | Simple but all-or-nothing |
| Savepoint | Controlled rollback within handler | More code, explicit control |
| Queueable | Independent async processes | Decoupled but async limits |
| Platform Event | Maximum decoupling | More infrastructure |
| @future | Simple fire-and-forget | Limited parameters |
Key Principles
- Make atomicity decisions explicit - Don't rely on default behavior accidentally
- Decouple independent processes - Use async for cross-domain operations
- Test cascade scenarios - Verify both success and failure paths
- Document trigger chains - Future maintainers need to understand dependencies
- Validate before deploying - Use
--dry-runto catch issues early