ApexTools: TriggerHandler base class & field-change helpers

•Apex Stem Docs
Apex StemApexToolsTriggerHandlerSalesforceApex
Detailed guide to the TriggerHandler base class in ApexTools. Covers the seven hooks, the fixed Trigger.cls pattern, and the field-change detection helpers that let you narrow afterUpdate handling in a single line.

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.

HookSignature
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.

APEX
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 __c straight into the trigger name. Apex identifiers may not contain a double underscore (Salesforce reserves it), so trigger SalesActivity__c on SalesActivity__c(...) fails to deploy with Invalid character in identifier. Give the trigger a different name (SalesActivityTrigger, for instance — match the file name too). Standard objects are fine as trigger Opportunity on Opportunity(...).

The Handler side overrides only the hooks it needs.

APEX
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.

MethodReturn TypePurpose
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:

APEX
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.