* ci: update package details and add GitHub workflows for skills validation and release * chore: update Node version, change license, and refactor skills validation script to TypeScript * chore: remove unused deps * fix: npm package * fix: flatten skills to pass validation * chore: add validation script to package.json and update GitHub workflow to use it * chore: enhance skills validation and update workflows for npm publishing * refactor: streamline skills validation process in workflow and script * refactor: improve documentation and structure of skills validation script * implement checks based on jeff's best practices doc * chore: add pull request template for skill submissions * chore: update pull request template with additional references and improve automated checks section * chore: update pull request template to clarify naming convention with a warning for gerund form * chore: update pull request template to reference skill authoring guide and enhance checklist structure * chore: enhance GitHub workflows for skills validation and release process * refactor: improve parsing logic for SKILL.md files and enhance error collection in validation * chore: update validation checks to enforce character limits for skill names and descriptions * fix: remove unnecessary whitespace in footer string in validate-skills.ts
12 KiB
Trigger Handler Patterns Reference
This guide covers common patterns for refactoring Salesforce triggers into handler classes with bulk-safe operations.
Pattern 1: Simple Handler Class
Best for: Triggers with 1-3 contexts and straightforward logic.
Structure
public class OpportunityTriggerHandler {
public void beforeInsert(List<Opportunity> newRecords) {
validateClosedWonAmount(newRecords);
}
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
updateDescriptionOnStageChange(newRecords, oldMap);
}
public void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
createTasksForClosedWon(newRecords, oldMap);
}
// Private helper methods below
private void validateClosedWonAmount(List<Opportunity> opportunities) {
for (Opportunity opp : opportunities) {
if (opp.StageName == 'Closed Won' &&
(opp.Amount == null || opp.Amount < 1000)) {
opp.addError('Closed Won opportunities must have Amount ≥ 1000.');
}
}
}
private void updateDescriptionOnStageChange(
List<Opportunity> newRecords,
Map<Id, Opportunity> oldMap
) {
for (Opportunity opp : newRecords) {
Opportunity oldOpp = oldMap.get(opp.Id);
if (opp.StageName != oldOpp.StageName) {
opp.Description = 'Stage changed from ' +
oldOpp.StageName + ' to ' + opp.StageName;
}
}
}
private void createTasksForClosedWon(
List<Opportunity> newRecords,
Map<Id, Opportunity> oldMap
) {
List<Task> tasksToInsert = new List<Task>();
for (Opportunity opp : newRecords) {
Opportunity oldOpp = oldMap.get(opp.Id);
// Check if stage changed to Closed Won
if (opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won') {
tasksToInsert.add(new Task(
WhatId = opp.Id,
OwnerId = opp.OwnerId,
Subject = 'Send thank-you',
Status = 'Not Started',
Priority = 'Normal',
ActivityDate = Date.today()
));
}
}
// Bulk DML outside loop
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
}
}
Trigger Delegation
trigger OpportunityTrigger on Opportunity (
before insert, before update, after update
) {
OpportunityTriggerHandler handler = new OpportunityTriggerHandler();
if (Trigger.isBefore) {
if (Trigger.isInsert) {
handler.beforeInsert(Trigger.new);
} else if (Trigger.isUpdate) {
handler.beforeUpdate(Trigger.new, Trigger.oldMap);
}
}
if (Trigger.isAfter && Trigger.isUpdate) {
handler.afterUpdate(Trigger.new, Trigger.oldMap);
}
}
Pattern 2: Handler with Database Methods
Best for: When you need granular error handling and partial success.
Key Features
- Uses
Database.insert()instead ofinsertfor partial saves - Returns
Database.SaveResultfor error handling - Logs errors without stopping execution
Example
private void createTasksForClosedWon(
List<Opportunity> newRecords,
Map<Id, Opportunity> oldMap
) {
List<Task> tasksToInsert = new List<Task>();
for (Opportunity opp : newRecords) {
Opportunity oldOpp = oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won') {
tasksToInsert.add(new Task(
WhatId = opp.Id,
OwnerId = opp.OwnerId,
Subject = 'Send thank-you',
Status = 'Not Started',
Priority = 'Normal',
ActivityDate = Date.today()
));
}
}
if (!tasksToInsert.isEmpty()) {
Database.SaveResult[] results = Database.insert(tasksToInsert, false);
// Log errors without stopping execution
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
System.debug('Failed to create task: ' + results[i].getErrors());
}
}
}
}
Pattern 3: Handler with Maps for Lookups
Best for: When you need to query related records for processing.
Key Features
- Pre-queries related records using Sets
- Uses Maps for O(1) lookups instead of nested loops
- Avoids SOQL in loops
Example
private void enrichOpportunitiesWithAccountData(List<Opportunity> opportunities) {
// Collect Account IDs
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : opportunities) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId);
}
}
// Single SOQL query outside loop
Map<Id, Account> accountMap = new Map<Id, Account>([
SELECT Id, Name, Industry, AnnualRevenue
FROM Account
WHERE Id IN :accountIds
]);
// Use Map for O(1) lookup
for (Opportunity opp : opportunities) {
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
Account acc = accountMap.get(opp.AccountId);
// Process with account data
opp.Description = 'Account Industry: ' + acc.Industry;
}
}
}
Pattern 4: Unified Handler Framework
Best for: Complex triggers with many contexts and cross-cutting concerns.
Structure
public abstract class TriggerHandler {
protected Boolean isBefore;
protected Boolean isAfter;
protected Boolean isInsert;
protected Boolean isUpdate;
protected Boolean isDelete;
protected Boolean isUndelete;
public void run() {
isBefore = Trigger.isBefore;
isAfter = Trigger.isAfter;
isInsert = Trigger.isInsert;
isUpdate = Trigger.isUpdate;
isDelete = Trigger.isDelete;
isUndelete = Trigger.isUndelete;
if (isBefore) {
if (isInsert) beforeInsert();
if (isUpdate) beforeUpdate();
if (isDelete) beforeDelete();
}
if (isAfter) {
if (isInsert) afterInsert();
if (isUpdate) afterUpdate();
if (isDelete) afterDelete();
if (isUndelete) afterUndelete();
}
}
protected virtual void beforeInsert() {}
protected virtual void beforeUpdate() {}
protected virtual void beforeDelete() {}
protected virtual void afterInsert() {}
protected virtual void afterUpdate() {}
protected virtual void afterDelete() {}
protected virtual void afterUndelete() {}
}
Concrete Handler
public class OpportunityTriggerHandler extends TriggerHandler {
private List<Opportunity> newRecords;
private List<Opportunity> oldRecords;
private Map<Id, Opportunity> newMap;
private Map<Id, Opportunity> oldMap;
public OpportunityTriggerHandler() {
this.newRecords = (List<Opportunity>) Trigger.new;
this.oldRecords = (List<Opportunity>) Trigger.old;
this.newMap = (Map<Id, Opportunity>) Trigger.newMap;
this.oldMap = (Map<Id, Opportunity>) Trigger.oldMap;
}
protected override void beforeInsert() {
validateClosedWonAmount();
}
protected override void beforeUpdate() {
updateDescriptionOnStageChange();
}
protected override void afterUpdate() {
createTasksForClosedWon();
}
// Private helper methods omitted for brevity
}
Trigger Delegation
trigger OpportunityTrigger on Opportunity (
before insert, before update, after update
) {
new OpportunityTriggerHandler().run();
}
Best Practices
1. Bulkification
Always process records in collections:
// ✓ Good: Collect DML outside loop
List<Task> tasksToInsert = new List<Task>();
for (Opportunity opp : opportunities) {
tasksToInsert.add(new Task(...));
}
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
// ✗ Bad: DML inside loop
for (Opportunity opp : opportunities) {
insert new Task(...); // SOQL/DML in loop!
}
2. Defensive Null Checks
// ✓ Good: Check for null before accessing
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
Account acc = accountMap.get(opp.AccountId);
// Safe to use acc
}
// ✗ Bad: Assumes data exists
Account acc = accountMap.get(opp.AccountId);
String industry = acc.Industry; // NullPointerException risk
3. Clear Method Names
// ✓ Good: Descriptive, verb-noun pattern
private void validateClosedWonAmount(List<Opportunity> opportunities)
private void createTasksForClosedWon(List<Opportunity> opportunities)
// ✗ Bad: Vague or unclear
private void validate(List<Opportunity> opportunities)
private void doStuff(List<Opportunity> opportunities)
4. Single Responsibility
Each handler method should do one thing:
// ✓ Good: Separate concerns
private void validateClosedWonAmount(List<Opportunity> opportunities)
private void validateRequiredFields(List<Opportunity> opportunities)
private void calculateDiscounts(List<Opportunity> opportunities)
// ✗ Bad: One method does everything
private void processOpportunities(List<Opportunity> opportunities)
5. Test Boundaries
Structure code to make testing easier:
// ✓ Good: Public method for testing, private for implementation
@TestVisible
private void createTasksForClosedWon(
List<Opportunity> newRecords,
Map<Id, Opportunity> oldMap
) {
// Implementation
}
Deployment Order
When deploying refactored triggers:
- Deploy handler class(es) first
- Update trigger to use handler
- Deploy test class
- Run all tests before production deployment
- Monitor debug logs for 24-48 hours after production deployment
Rollback Strategy
Keep the old trigger code commented out or in version control:
trigger OpportunityTrigger on Opportunity (...) {
// New handler approach
new OpportunityTriggerHandler().run();
/* OLD CODE - REMOVE AFTER 1 WEEK IF NO ISSUES
if (Trigger.isBefore && Trigger.isInsert) {
for (Opportunity o : Trigger.new) {
// old logic
}
}
*/
}
Common Pitfalls
Pitfall 1: Recursive Triggers
Problem: Handler calls DML which triggers the same trigger again.
Solution: Use static flag to prevent recursion:
public class OpportunityTriggerHandler {
private static Boolean isExecuting = false;
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
if (isExecuting) return;
isExecuting = true;
try {
// Your logic here
} finally {
isExecuting = false;
}
}
}
Pitfall 2: Mixed Context Logic
Problem: Before-context logic mixed with after-context logic.
Solution: Keep context methods separate and focused:
// ✓ Good: Separate methods per context
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
public void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
// ✗ Bad: Mixed logic in one method
public void handleUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
Pitfall 3: Over-Engineering
Problem: Using complex framework for simple triggers.
Solution: Choose the right pattern for your complexity:
- 1-3 contexts with simple logic → Simple Handler (Pattern 1)
- 3-5 contexts with moderate complexity → Handler with Database Methods (Pattern 2)
- 5+ contexts with cross-cutting concerns → Unified Framework (Pattern 4)