There are already many trigger handler libraries out there, but this one has some different approaches or advantages such as state sharing, built in helper methods etc.
- Built-in helpers to perform common operations on trigger properties, such as detect field changes.
- Control flow of handler execution with
context.next()
,context.stop()
, andcontext.skips
. - Optionally register and control handlers with custom metadata type settings.
This package is the minimal installation which only includes two classes Triggers.cls
and TriggersTest.cls
.
Environment | Installation Link | Version |
---|---|---|
Production, Developer | ![]() |
ver 1.2.1 |
Sandbox | ![]() |
ver 1.2.1 |
This package can be optionally installed to extend a new feature (custom metadata type settings) for the above one. It introduces additional but only one SOQL query to a custom metadata type. If your system already reaches some governor limit around SOQL queries, can consider deploy this one later. Note: The above package is required to be installed before this one.
Environment | Installation Link | Version |
---|---|---|
Production, Developer | ![]() |
ver 1.2.1 |
Sandbox | ![]() |
ver 1.2.1 |
- Support custom metadata type settings to register trigger handlers. (jump to section)
- Improve Consistency (v1.2.1): Ids returned by
props.filterChangedAny
andprops.filterChangedAll
are now in the same Id orders ofprops.newList
.
This is an example about how handlers can be registered in triggers. As you have noticed, we are creating same handlers for different trigger events. This is because handlers may need to execute in different orders for different trigger events, we need to provide developers great controls over the order of executions.
trigger AccountTrigger on Account (before update, after update) {
Triggers.prepare()
.beforeUpdate()
.bind(new MyAccountHandler())
.bind(new AnotherAccountHandler())
.afterUpdate()
.bind(new AnotherAccountHandler())
.bind(new MyAccountHandler())
.execute();
}
This feature is only available when ApexTriggerHandlerExt
package is installed, or its metadata is manually deployed. Here are some sample records of Apex_Trigger_Handler_Setting__mdt
, which can provide fine-grained control of trigger handler behaviors at runtime, such as:
- To register trigger handlers for a particular sObject trigger event (
SObject__c
,Trigger_Event__c
,Handler_Class__c
). - To activate or deactivate trigger handlers (
Active__c
). - To reorder trigger handlers (
Execution_Order__c
). - To optionally group trigger handlers with tags (
Tag__c
).
SObject__c | Trigger_Event__c | Handler_Class__c | Execution_Order__c | Tag__c | Active__c |
---|---|---|---|---|---|
Account | BEFORE_UPDATE | AccountTriggerHandler1 | 1 | tag1 | TRUE |
Account | BEFORE_UPDATE | AccountTriggerHandler2 | 2 | TRUE | |
Account | BEFORE_UPDATE | AccountTriggerHandler3 | 3 | FALSE | |
Account | AFTER_UPDATE | AccountTriggerHandler4 | 1 | tag1 | TRUE |
Account | AFTER_UPDATE | AccountTriggerHandler5 | 2 | tag2 | TRUE |
Account | AFTER_UPDATE | AccountTriggerHandler6 | 3 | tag2 | TRUE |
Two additional APIs are provided to load the handlers from the above settings, load()
and load(tag)
. Their usages are explained in the following comments.
trigger AccountTrigger on Account (before update, after update) {
Triggers.prepare()
.beforeUpdate()
.bind(new MyAccountHandler())
.load() // load all active handlers under Account BEFORE_UPDATE
// - AccountTriggerHandler1
// - AccountTriggerHandler2
.bind(new AnotherAccountHandler())
.afterUpdate()
.bind(new AnotherAccountHandler())
.load('tag1') // load all active handlers with 'tag1' under Account AFTER_UPDATE
// - AccountTriggerHandler4
.bind(new MyAccountHandler())
.load('tag2') // load all active handlers with 'tag2' under Account AFTER_UPDATE
// - AccountTriggerHandler5
// - AccountTriggerHandler6
.execute();
}
The following demo is using Apex DI as a dependency injection (DI) framework.
trigger AccountTrigger on Account (before update, after update) {
// reference interfaces and decouple trigger from implementations
DI.Module salesModule = DI.getModule(SalesModule.class);
Triggers.prepare()
.beforeUpdate()
.bind((Triggers.Handler) salesModule.getService(IMyAccountHandler.class))
.bind((Triggers.Handler) salesModule.getService(IAnotherAccountHandler.class))
.afterUpdate()
.bind((Triggers.Handler) salesModule.getService(IAnotherAccountHandler.class))
.bind((Triggers.Handler) salesModule.getService(IMyAccountHandler.class))
.execute();
}
public class SalesModule extends DI.Module {
// register handler implementation against interfaces
protected overried void configure(DI.ServiceCollection services) {
services.addTransient('IMyAccountHandler', 'MyAccountHandler');
services.addTransient('IAnotherAccountHandler', 'AnotherAccountHandler');
}
}
public class IMyAccountHandler extends Triggers.Handler {}
public class MyAccountHandler implements
IMyAccountHandler, Triggers.BeforeUpdate, Triggers.AfterUpdate {}
public class IAnotherAccountHandler extends Triggers.Handler {}
public class AnotherAccountHandler implements
IAnotherAccountHandler, Triggers.BeforeUpdate, Triggers.AfterUpdate {}
To create a trigger handler, you will need to create a class that implements the Triggers.Handler
interface and its criteria
method. Please check the comments below for detailed explanations and tricks to customize a trigger handler.
// 1. Use interfaces instead of a base class to extend a custom handler. With interface
// approach we can declare only the needed interfaces explicitly, which is much cleaner
// and clearer.
public class MyAccountHandler implements Triggers.Handler,
Triggers.BeforeUpdate,
Triggers.AfterUpdate {
// 2. There is a "criteria" stage before any handler execution. This gives
// developers opportunities to turn on and off the handlers according to
// configurations at run time.
public Boolean criteria(Triggers.Context context) {
return Triggers.WHEN_ALWAYS;
// 3. There are also helper methods to check if certain fields have changes
// return context.props.isChangedAny(Account.Name, Account.Description);
// return context.props.isChangedAll(Account.Name, Account.Description);
}
public void beforeUpdate(Triggers.Context context) {
then(context);
}
public void afterUpdate(Triggers.Context context) {
then(context);
}
private void then(Triggers.Context context) {
// 4. All properties on Trigger have been exposed to context.props.
// Direct reference of Trigger.old and Trigger.new should be avoided,
// instead use context.props.oldList and context.props.newList.
if (context.props.isUpdate) {
// 5. Use context.state to pass query or computation results down to all
// following handlers within the current trigger context, i.e. before update.
// Before update and after update are considered as differenet contexts.
Integer counter = (Integer) context.state.get('counter');
if (counter == null) {
context.state.put('counter', 0);
} else {
context.state.put('counter', counter + 1);
}
// 6. Use context.skips or Triggers.skips to prevent specific handlers from
// execution. Please do remember restore the handler when appropriate.
context.skips.add(ContactHandler.class);
List<Contact> contacts = ...;
Database.insert(contacts);
context.skips.remove(ContactHandler.class);
// 7-1. Call context.next() to execute the next handler. It is optional to use,
// unless some following up logics need to be performed after all following
// handlers finished.
context.next();
// 7-2. If context.stop() is called instead of context.next(), any following
// handlers won't be executed, just like the STOP in process builder.
context.stop();
}
}
}
Global static variable Triggers.skips
references the same context.skips
, so you can use it to skip handlers outside of the handler contexts. For example, when you want to skip a trigger handler in a batch class:
global class AccountUpdateBatch implements Database.Batchable<SObject> {
...
global void execute(Database.BatchableContext BC, List<sObject> scope){
Triggers.skips.add(MyAccountHandler.class);
// Update accounts...
Triggers.skips.remove(MyAccountHandler.class);
}
...
}
The following method is private but @TestVisible
, it can be used in test methods to supply mockup records for old and new lists. So we don't need to perform DMLs to trigger the handlers.
@isTest
static void test_AccountTriggerHandler_BeforeUpdate {
List<SObject> oldList = new List<Account> {
new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'Old Name 1'),
new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'Old Name 2'),
new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'Old Name 3')}
List<SObject> newList = new List<Account> {
new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'New Name 1'),
new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'New Name 2'),
new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'New Name 3')}
Triggers.prepare(TriggerOperation.Before_Update, oldList, newList)
.beforeUpdate().bind(new MyAccountHandler()).execute();
}
The following demo is using Apex Test Kit as a mockup data library. The behavior will be the same as the above example, but a sophisticated mock data library can also generate mockup data with read-only fields, such as formula fields, roll-up summary fields and system fields.
@isTest
static void test_AccountTriggerHandler_BeforeUpdate {
// automatically generate fake IDs for oldList
List<SObject> oldList = ATK.prepare(Account.SObjectType, 3)
.field(Account.Name).index('Old Name {0}')
.mock().get(Account.SObjectType);
// IDs in oldList will be preserved in the newList
List<SObject> newList = ATK.prepare(Account.SObjectType, oldList)
.field(Account.Name).index('New Name {0}')
.mock().get(Account.SObjectType);
Triggers.prepare(TriggerOperation.Before_Update, oldList, newList)
.beforeUpdate().bind(new MyAccountHandler()).execute();
}
Interface | Method to Implement |
---|---|
Triggers.Handler | Boolean criteria(Triggers.Context context); |
Triggers.BeforeInsert | void beforeInsert(Triggers.Context context); |
Triggers.AfterInsert | void afterInsert(Triggers.Context context); |
Triggers.BeforeUpdate | void beforeUpdate(Triggers.Context context); |
Triggers.AfterUpdate | void afterUpdate(Triggers.Context context); |
Triggers.BeforeDelete | void beforeDelete(Triggers.Context context); |
Triggers.AfterDelete | void afterDelete(Triggers.Context context); |
Triggers.BeforeUndelete | void afterUndelete(Triggers.Context context); |
Property/Method | Type | Description |
---|---|---|
context.props | Triggers.Props | All properties on Trigger are exposed by this class. In addition there are frequently used helper methods and a convinient sObjectType property, in case reflection is needed . |
context.state | Map<Object, Object> | A map provided for developers to pass any value down to other handlers. |
context.skips | Triggers.Skips | A set to store handlers to be skipped. Call the following methods to manage skips: context.skips.add() , context.skips.remove() , context.skips.clear() context.skips.contains() etc. |
context.next() | void | Call the next handler. |
context.stop() | void | Stop execute any following handlers. A bit like the the stop in process builders. |
Property | Type | Description |
---|---|---|
sObjectType | SObjectType | The current SObjectType. |
isExecuting | Boolean | Trigger.isExecuting |
isBefore | Boolean | Trigger.isBefore |
isAfter | Boolean | Trigger.isAfter |
isInsert | Boolean | Trigger.isInsert |
isUpdate | Boolean | Trigger.isUpdate |
isDelete | Boolean | Trigger.isDelete |
isUndelete | Boolean | Trigger.isUndelete |
oldList | List<SObject> | Trigger.old |
oldMap | Map<Id, SObject> | Trigger.oldMap |
newList | List<SObject> | Trigger.new |
newMap | Map<Id, SObject> | Trigger.newMap |
operationType | TriggerOperation | Trigger.operationType |
size | Integer | Trigger.size |
Note: the following isChanged
method has the same behavior has the ISCHANGED
formula:
- This function returns
false
when evaluating any field on a newly created record.- If a text field was previously blank, this function returns
true
when it contains any value.- For number, percent, or currency fields, this function returns
true
when:
- The field was blank and now contains any value
- The field was zero and now is blank
- The field was zero and now contains any other value
Method | Type | Description |
---|---|---|
- isChanged(SObjectField field1) |
Boolean | Check if any record has a field changed during an update. |
- isChangedAny(SObjectField field1, SObjectField field2) - isChangedAny(SObjectField field1, SObjectField field2, SObjectField field3) - isChangedAny(List<SObjectField> fields) |
Boolean | Check if any record has multiple fields changed during an update. Return true if any specified field is changed. |
- isChangedAll(SObjectField field1, SObjectField field2) - isChangedAll(SObjectField field1, SObjectField field2, SObjectField field3) - isChangedAll(List<SObjectField> fields) |
Boolean | Check if any record has multiple fields changed during an update. Return true only if all specified fields are changed. |
- filterChanged(SObjectField field1) |
List<Id> | Filter IDs of records have a field changed during an update. |
- filterChangedAny(SObjectField field1, SObjectField field2) - filterChangedAny(SObjectField field1, SObjectField field2, SObjectField field3) - filterChangedAny(List<SObjectField> fields) |
List<Id> | Filter IDs of records have multiple fields changed during an update. Return IDs if any specified field is changed. |
- filterChangedAll(SObjectField field1, SObjectField field2) - filterChangedAll(SObjectField field1, SObjectField field2, SObjectField field3) - filterChangedAll(List<SObjectField> fields) |
List<Id> | Filter IDs of records have multiple fields changed during an update. Return IDs only if all specified fields are changed. |
BSD 3-Clause License