ApexTools: TriggerHandler base class & field-change helpers
This document explains the TriggerHandler base class that ApexTools provides. For an index that includes the other tools, see the ApexTools Guide.
What You Can Do
Simply by extending the TriggerHandler base class, the entry-point processing from Triggers settles into a clean shape: "a trigger file that only declares the seven events" plus "a handler class that only writes the hooks you override". A built-in helper for "narrowing down to records where specific fields changed" lets afterUpdate filtering be written in a single line.
Inheritance: seven hooks plus andFinally
Each Handler extends the TriggerHandler base class and overrides only the hooks it needs. They are all protected virtual, so any hook you don't override does nothing. There are eight in total: seven matching the trigger events, plus andFinally, which runs last regardless of context.
| Hook | Signature |
|---|---|
beforeInsert | (List<SObject> newRecords) |
beforeUpdate | (Map<Id, SObject> newMap, Map<Id, SObject> oldMap) |
beforeDelete | (Map<Id, SObject> deletedMap) |
afterInsert | (Map<Id, SObject> newMap) |
afterUpdate | (Map<Id, SObject> newMap, Map<Id, SObject> oldMap) |
afterDelete | (Map<Id, SObject> deletedMap) |
afterUndelete | (Map<Id, SObject> undeletedMap) |
andFinally | () — always called last (in any context) |
Integrated With the Fixed Trigger.cls Pattern
The trigger file follows Salesforce convention and uses a fixed form. Declare all seven events and call the Handler in one line.
trigger Opportunity on Opportunity(
before insert,
before update,
before delete,
after insert,
after update,
after delete,
after undelete
) {
(new TriggerOppHandler()).execute();
}
🚨 On a custom object you cannot put
__cstraight into the trigger name. Apex identifiers may not contain a double underscore (Salesforce reserves it), sotrigger SalesActivity__c on SalesActivity__c(...)fails to deploy withInvalid character in identifier. Give the trigger a different name (SalesActivityTrigger, for instance — match the file name too). Standard objects are fine astrigger Opportunity on Opportunity(...).
The Handler side overrides only the hooks it needs.
public with sharing class TriggerOppHandler extends TriggerHandler {
protected override void afterInsert(Map<Id, SObject> newRecordsMap) {
Set<Id> opportunityIds = newRecordsMap.keySet();
(new CopyAccountIndustryToOpportunity(opportunityIds)).invoke();
}
}
Field-Change Detection Helpers
It's a frequent case to want to narrow afterUpdate to "records where specific fields changed". The TriggerHandler base class provides helpers for exactly that.
| Method | Return Type | Purpose |
|---|---|---|
getUpdatedRecordsWithChangedField(SObjectField field) | List<SObject> | Records where a single field changed |
getUpdatedRecordsWithChangedFields(List<SObjectField> fields) | List<SObject> | Records where any of multiple fields changed |
getUpdateRecordIdsWithChangedField(SObjectField field) | Set<Id> | Id version of the above |
getUpdateRecordIdsWithChangedFields(List<SObjectField> fields) | Set<Id> | Id version of the above |
Example usage:
public with sharing class TriggerOppHandler extends TriggerHandler {
protected override void afterUpdate(Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {
Set<Id> needIds = this.getUpdateRecordIdsWithChangedFields(new List<SObjectField>{
Opportunity.AccountId,
Opportunity.StageName
});
(new RegenerateCollectionUsecase(needIds)).invoke();
}
}
The logic "do something only when a specific field changed" consolidates into a single line, and the Handler still keeps its slim "only condition checks and Usecase invocation" shape.
Other Notes
Case-Only Changes Count as Changes (v1.2.0+)
An update that only changes letter case, such as 'acme' to 'ACME', is now reported as a change. Up to v1.1.0 it was missed, because Apex's String != ignores case. After upgrading to v1.2.0, logic keyed on such a field will start running for these updates.
Values that Salesforce normalizes on save (picklist case, Email, multi-select order) already arrive normalized in before update, so an update that saves back to the same value is not reported as a change (measured). A value assigned by Apex earlier in the same before update is not normalized yet, though: if an earlier handler writes StageName = 'prospecting', it is reported as a change.
Hooks You Don't Override Just Do Nothing
The hooks on the TriggerHandler base class are all protected virtual and do nothing by default. A Handler that only wants to handle afterInsert overrides only afterInsert — no need to fill other hooks with empty methods.
When to Use andFinally
andFinally() is a hook that is always called last in any context. Use it for "processing that must always run last, regardless of the before / after type" (finalizing audit logs, wrapping up Trace, etc.). Most Handlers don't need it.
Change-detection helpers return every record on insert
When there is no Trigger.oldMap (or it is empty), the field change-detection helpers skip the comparison and return all of Trigger.new as-is. An insert has no "before" state, so every record is treated as changed. Calling them from afterInsert filters nothing — think of the filtering as something that only matters in the update hooks. The Id variants return {null} in before insert, where no Id exists yet. Do not use the helpers in the delete / undelete hooks.
Cannot be instantiated outside triggers and tests
The TriggerHandler constructor throws an exception when neither Trigger.isExecuting nor Test.isRunningTest() is true. You cannot new up a Handler from anonymous Apex or a batch and reuse it there. When you want to run the same logic from outside the Handler, call the Usecase directly instead of the Handler.
Read Next
- ApexTools Guide: the entry point that includes the other tools in ApexTools
- Handler-Usecase Architecture: the core Apex Stem architecture where
TriggerHandlershines - Apex Stem Introduction Guide: four steps with working code