Trigger Modularizaation
Overview
This guide demonstrates how to migrate from monolithic trigger handlers to a modular, metadata-driven trigger actions framework that enables small, focused, testable trigger logic units.
The Problem with Traditional Trigger Handlers
Most Salesforce implementations suffer from:
Monolithic trigger handlers with hundreds/thousands of lines
Mixed responsibilities in a single class
Poor testability - need to test entire handler
Merge conflicts when multiple developers work on same handler
No configuration - everything is hardcoded
Difficult debugging - hard to isolate issues
Typical Monolithic Pattern
// What we typically see - massive trigger handler classes
public class AccountTriggerHandler extends TriggerHandler {
public override void beforeInsert() {
// 50+ lines of validation logic
validateAccounts();
// 30+ lines of defaulting logic
setDefaultValues();
// 40+ lines of formatting logic
formatPhoneNumbers();
// 60+ lines of duplicate checking
checkForDuplicates();
}
public override void afterInsert() {
// 100+ lines creating related records
createDefaultContacts();
createTeamMembers();
createShares();
// 80+ lines of integration logic
sendToExternalSystem();
publishPlatformEvents();
// 40+ lines of notification logic
sendEmailAlerts();
}
public override void beforeUpdate() {
// 200+ lines of various business logic
validateStatusTransitions();
calculateScores();
updateDerivedFields();
enforceBusinessRules();
// ... and on and on
}
// Often 1000+ lines total in production handlers
}The Trigger Actions Solution
Core Concept
Instead of one large handler, create small, focused trigger action classes that each do ONE thing, configured through Custom Metadata.
Architecture Overview
Trigger → fflib_TriggerHandler → Custom Metadata → Individual Action ClassesImplementation Pattern
Step 1: Simple Trigger
// The trigger is now just a dispatcher
trigger CasesTrigger on Case (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
new fflib_TriggerHandler().run();
}Step 2: Individual Trigger Actions
Each action is a small, focused class:
// Action 1: Send escalation emails
public class TA_Case_SendEscalationEmails extends fflib_TriggerAction {
public override void onAfterUpdate() {
List<Case> escalatedCases = getEscalatedCases();
if (escalatedCases.isEmpty()) return;
publishEscalationEvents(escalatedCases);
}
private List<Case> getEscalatedCases() {
return (List<Case>) triggerContext.getChangedRecords(
new Set<SObjectField>{Case.SubStatus__c}
).stream()
.filter(c -> c.SubStatus__c == 'Escalated')
.collect(Collectors.toList());
}
private void publishEscalationEvents(List<Case> cases) {
List<Case_Escalation_Event__e> events = new List<Case_Escalation_Event__e>();
for (Case c : cases) {
events.add(new Case_Escalation_Event__e(
CaseId__c = c.Id
));
}
EventBus.publish(events);
}
}
// Action 2: Format phone numbers
public class TA_Case_FormatPhoneNumbers extends fflib_TriggerAction {
public override void onBeforeInsert() {
formatPhones();
}
public override void onBeforeUpdate() {
formatPhones();
}
private void formatPhones() {
for (Case c : (List<Case>) triggerContext.newList) {
if (String.isNotBlank(c.ContactPhone)) {
c.ContactPhone = PhoneFormatter.format(c.ContactPhone);
}
}
}
}
// Action 3: Set default values
public class TA_Case_SetDefaults extends fflib_TriggerAction {
public override void onBeforeInsert() {
for (Case c : (List<Case>) triggerContext.newList) {
if (c.Priority == null) {
c.Priority = 'Medium';
}
if (c.Origin == null) {
c.Origin = 'Web';
}
}
}
}
// Action 4: Track field history
public class TA_Case_TrackHistory extends fflib_TriggerAction {
public override void onAfterUpdate() {
List<Field_History__c> histories = new List<Field_History__c>();
for (Case newCase : (List<Case>) triggerContext.newList) {
Case oldCase = (Case) triggerContext.oldMap.get(newCase.Id);
if (newCase.Status != oldCase.Status) {
histories.add(createHistory(
newCase.Id,
'Status',
oldCase.Status,
newCase.Status
));
}
}
if (!histories.isEmpty()) {
insert histories;
}
}
private Field_History__c createHistory(Id recordId, String field, Object oldVal, Object newVal) {
return new Field_History__c(
Record_Id__c = recordId,
Field_Name__c = field,
Old_Value__c = String.valueOf(oldVal),
New_Value__c = String.valueOf(newVal),
Changed_Date__c = System.now()
);
}
}Step 3: Configure Actions in Custom Metadata
<!-- fflib_TriggerAction.Case_SendEscalationEmails.md-meta.xml -->
<CustomMetadata xmlns="http://soap.sforce.com/2006/04/metadata">
<label>Case - Send Escalation Emails</label>
<protected>false</protected>
<values>
<field>ObjectType__c</field>
<value>Case</value>
</values>
<values>
<field>ImplementationType__c</field>
<value>TA_Case_SendEscalationEmails</value>
</values>
<values>
<field>AfterUpdate__c</field>
<value>true</value>
</values>
<values>
<field>Sequence__c</field>
<value>10</value>
</values>
<values>
<field>Active__c</field>
<value>true</value>
</values>
</CustomMetadata>
<!-- fflib_TriggerAction.Case_FormatPhoneNumbers.md-meta.xml -->
<CustomMetadata xmlns="http://soap.sforce.com/2006/04/metadata">
<label>Case - Format Phone Numbers</label>
<protected>false</protected>
<values>
<field>ObjectType__c</field>
<value>Case</value>
</values>
<values>
<field>ImplementationType__c</field>
<value>TA_Case_FormatPhoneNumbers</value>
</values>
<values>
<field>BeforeInsert__c</field>
<value>true</value>
</values>
<values>
<field>BeforeUpdate__c</field>
<value>true</value>
</values>
<values>
<field>Sequence__c</field>
<value>5</value>
</values>
<values>
<field>Active__c</field>
<value>true</value>
</values>
</CustomMetadata>Advanced Patterns
1. Conditional Trigger Actions
public class TA_Case_ConditionalAction extends fflib_TriggerAction {
// Only run for specific record types
public override Boolean shouldRun() {
if (triggerContext.isInsert) {
return hasTargetRecordType();
}
return false;
}
private Boolean hasTargetRecordType() {
Set<Id> targetRecordTypes = new Set<Id>{
Schema.SObjectType.Case.getRecordTypeInfosByDeveloperName()
.get('Support_Case').getRecordTypeId()
};
for (Case c : (List<Case>) triggerContext.newList) {
if (targetRecordTypes.contains(c.RecordTypeId)) {
return true;
}
}
return false;
}
public override void onAfterInsert() {
// Action logic here
}
}2. Stateful Trigger Actions
// Track state across trigger contexts
public class TA_Case_PreventRecursion extends fflib_TriggerAction {
private static Set<Id> processedIds = new Set<Id>();
public override void onAfterUpdate() {
List<Case> toProcess = new List<Case>();
for (Case c : (List<Case>) triggerContext.newList) {
if (!processedIds.contains(c.Id)) {
toProcess.add(c);
processedIds.add(c.Id);
}
}
if (!toProcess.isEmpty()) {
processRecords(toProcess);
}
}
}3. Async Trigger Actions
public class TA_Case_AsyncProcessing extends fflib_TriggerAction {
public override void onAfterInsert() {
// Collect IDs for async processing
Set<Id> caseIds = triggerContext.newMap.keySet();
// Enqueue async job
System.enqueueJob(new AsyncProcessor(caseIds));
}
public class AsyncProcessor implements Queueable {
private Set<Id> recordIds;
public AsyncProcessor(Set<Id> recordIds) {
this.recordIds = recordIds;
}
public void execute(QueueableContext context) {
// Async processing logic
}
}
}4. Trigger Action with Dependencies
public class TA_Case_WithDependencies extends fflib_TriggerAction {
@TestVisible
private ICaseService caseService {
get {
if (caseService == null) {
caseService = (ICaseService) Application.Service.newInstance(
ICaseService.class
);
}
return caseService;
}
set;
}
public override void onAfterUpdate() {
// Use injected service
caseService.processUpdatedCases(
triggerContext.newMap.keySet()
);
}
}Testing Trigger Actions
Individual Action Testing
@IsTest
private class TA_Case_SendEscalationEmailsTest {
@IsTest
static void testEscalationEmailsSent() {
// Create test data
Case testCase = new Case(
Status = 'New',
SubStatus__c = 'In Progress'
);
insert testCase;
// Update to trigger action
testCase.SubStatus__c = 'Escalated';
Test.startTest();
update testCase;
Test.stopTest();
// Verify platform event was published
List<Case_Escalation_Event__e> events = [
SELECT CaseId__c
FROM Case_Escalation_Event__e
WHERE CaseId__c = :testCase.Id
];
System.assertEquals(1, events.size());
}
@IsTest
static void testNoEscalationForOtherStatus() {
Case testCase = new Case(
Status = 'New',
SubStatus__c = 'In Progress'
);
insert testCase;
testCase.SubStatus__c = 'Resolved';
Test.startTest();
update testCase;
Test.stopTest();
// Verify no event published
// Test passes if no exception
}
}Testing with Mocks
@IsTest
private class TA_Case_WithDependenciesTest {
@IsTest
static void testServiceCalled() {
fflib_ApexMocks mocks = new fflib_ApexMocks();
ICaseService mockService = (ICaseService) mocks.mock(ICaseService.class);
// Create action and inject mock
TA_Case_WithDependencies action = new TA_Case_WithDependencies();
action.caseService = mockService;
// Create trigger context
Case oldCase = new Case(Id = fflib_IDGenerator.generate(Case.SObjectType));
Case newCase = oldCase.clone(true);
fflib_TriggerContext context = new fflib_TriggerContext();
context.newMap = new Map<Id, Case>{newCase.Id => newCase};
context.oldMap = new Map<Id, Case>{oldCase.Id => oldCase};
action.triggerContext = context;
Test.startTest();
action.onAfterUpdate();
Test.stopTest();
// Verify service was called
((ICaseService) mocks.verify(mockService, 1))
.processUpdatedCases(context.newMap.keySet());
}
}Migration Strategy
From Monolithic to Modular
Analyze Existing Handler
List all operations in current handler
Group related operations
Identify dependencies
Create Trigger Actions
One action per logical operation
Keep actions under 50 lines
Single responsibility principle
Configure Metadata
Create custom metadata records
Set appropriate sequence
Configure trigger contexts
Gradual Migration
// Transitional approach - both patterns trigger AccountTrigger on Account (...) { // New pattern new fflib_TriggerHandler().run(); // Old pattern (temporarily) AccountTriggerHandler.handle(); }Testing
Test each action individually
Integration test full flow
Performance test at scale
Benefits of Trigger Actions
Development Benefits
Focused Classes: Each action does one thing
Parallel Development: No merge conflicts
Easy Testing: Test individual actions
Reusability: Actions can be shared across objects
Operational Benefits
Configuration: Enable/disable without deployment
Sequencing: Control execution order via metadata
Debugging: Isolate issues to specific actions
Performance: Only run necessary actions
Maintenance Benefits
Clear Responsibilities: Easy to understand
Version Control: Better diff visibility
Code Reviews: Review small changes
Documentation: Self-documenting actions
Best Practices
1. Naming Conventions
TA_{Object}_{Action}
Examples:
- TA_Case_SendEscalationEmails
- TA_Account_ValidateAddress
- TA_Opportunity_CalculateScore2. Action Granularity
One business operation per action
Keep under 50 lines of code
Single trigger context per action
3. Metadata Organization
<CustomMetadata>
<label>{Object} - {Description}</label>
<values>
<field>ObjectType__c</field>
<value>{SObject API Name}</value>
</values>
<values>
<field>ImplementationType__c</field>
<value>{Class Name}</value>
</values>
<values>
<field>Sequence__c</field>
<value>{10, 20, 30...}</value> <!-- Leave gaps -->
</values>
</CustomMetadata>4. Error Handling
public override void onAfterInsert() {
try {
// Action logic
} catch (Exception e) {
// Log error but don't fail transaction
Logger.error('Failed to execute action', e);
Logger.saveLog();
}
}Common Patterns
Validation Actions
public class TA_Object_Validate extends fflib_TriggerAction {
public override void onBeforeInsert() {
for (SObject record : triggerContext.newList) {
if (!isValid(record)) {
record.addError('Validation failed');
}
}
}
}Field Update Actions
public class TA_Object_UpdateFields extends fflib_TriggerAction {
public override void onBeforeUpdate() {
for (SObject record : triggerContext.newList) {
SObject oldRecord = triggerContext.oldMap.get(record.Id);
if (hasRelevantChange(record, oldRecord)) {
updateDerivedFields(record);
}
}
}
}Integration Actions
public class TA_Object_PublishEvent extends fflib_TriggerAction {
public override void onAfterInsert() {
List<Platform_Event__e> events = new List<Platform_Event__e>();
for (SObject record : triggerContext.newList) {
events.add(createEvent(record));
}
EventBus.publish(events);
}
}Monitoring and Debugging
Custom Metadata Dashboard
Create reports/dashboards to visualize:
Active trigger actions per object
Execution sequence
Recently modified actions
Production Logging with Nebula Logger
public override void onAfterUpdate() {
// Use Nebula Logger for production monitoring
Logger.info('Starting escalation email processing')
.setField('recordCount', triggerContext.newList.size())
.setField('triggerAction', 'TA_Case_SendEscalationEmails');
try {
// Action logic
processEscalations();
Logger.info('Completed escalation email processing');
} catch (Exception e) {
Logger.error('Failed to process escalations', e);
} finally {
Logger.saveLog();
}
}
// For development/debugging only (remove before deployment)
private void temporaryDebugLogging() {
// System.debug should only be used during development
// and must be removed before production deployment
System.debug('Temporary debug for investigation');
}Conclusion
The Trigger Actions pattern transforms unmaintainable monolithic handlers into:
Modular single-purpose actions
Configurable metadata-driven execution
Testable isolated units
Maintainable focused classes
This approach has proven successful in production systems with hundreds of trigger actions across dozens of objects, enabling teams to work in parallel without conflicts while maintaining code quality.
Last updated
Was this helpful?