The trigger handler is one of the three parts of Apex Forge Platform, the Apex I keep in a public repository at github.com/WTWatson/apex-forge-platform on GitHub. Three classes make it up: ForgeTriggerHandler, the base class a handler extends; ForgeTriggerDispatcher, which the trigger file calls; and ForgeTriggerContext, the snapshot of the trigger variables a handler receives. This page documents what the code does at commit f9bf157, with the class and line for each claim, in the order a developer meets it. How to get the code into a project is on the overview page.

How does a trigger reach a handler?

The trigger file calls the dispatcher and nothing else. ForgeTriggerDispatcher.run() has two forms. run(ForgeTriggerHandler handler) calls handle() on the one handler (ForgeTriggerDispatcher.cls, lines 12 to 15). run(List<ForgeTriggerHandler> handlers) loops over the list and calls handle() on each in turn (lines 17 to 23). The order is the order of the list, which the header states as the design: an ordered list of handler instances, run in sequence (lines 4 to 5). The loop has no try and catch, so an exception in one handler stops the loop and the handlers after it do not run.

How do I write a handler?

A handler is a class that extends ForgeTriggerHandler, a public virtual class (ForgeTriggerHandler.cls, line 9), and overrides onTrigger(ForgeTriggerContext ctx), which the base class declares as protected virtual with an empty body (line 63). The override is written protected override void onTrigger(ForgeTriggerContext ctx), as the test’s inner handler has it (ForgeTriggerHandlerTest.cls, line 8). Everything the handler needs arrives on ctx. It never reads Trigger.new or any other trigger variable itself, because handle() reads them once and passes the snapshot in (ForgeTriggerHandler.cls, lines 56 to 60). handle() is public and not virtual (line 46), so a subclass cannot replace the checks it runs first.

What stops a handler running twice?

A run count per handler class, held for the transaction. handle() works out the handler’s name from String.valueOf(this), keeping the text before the first colon (line 48), and reads that name’s count from a static map (lines 11 and 49). If the name is bypassed, it returns (line 51). If the count has reached getMaxReEntries(), it returns (line 52). Otherwise it adds one to the count (line 54), builds the context and calls onTrigger() (lines 56 to 60).

getMaxReEntries() is virtual and returns 1 (line 14), so by default a handler class runs once per transaction. Override it to return a higher number, as the test’s ReEntrantHandler does with 3 (ForgeTriggerHandlerTest.cls, lines 17 to 19). The map is a static, and Apex static variables are static only within the scope of the transaction, so every transaction starts the count from zero. Two static methods serve tests: getRunCount(name) returns the count, or 0 for a name it has never seen (lines 36 to 39), and resetRunCounts() empties the map (lines 41 to 44).

The key is whatever sits before that first colon. Salesforce’s String class reference says valueOf(toConvert) returns a string representation of the object and, for a user-defined type, calls an overridden toString() if there is one. It does not document the format when there is none, and the code relies on that format beginning with the class name. No test calls handle() directly, and the one that reaches it through the dispatcher, dispatcherRunsListInOrder, asserts nothing, so the suite never checks what line 48 yields. The bypass tests pass literal names, TestHandler on line 29 and HandlerA and HandlerB on lines 43 to 44, and no assertion compares a literal with a name the code derived. Treat the handler name as the class name and keep handlers as top-level classes.

How do I bypass a handler?

Four static methods on ForgeTriggerHandler manage a static set of names (line 12). bypass(name) adds a name (lines 16 to 19), clearBypass(name) removes one (lines 21 to 24), clearAllBypasses() empties the set (lines 26 to 29) and isBypassed(name) reports whether a name is in it (lines 31 to 34). handle() checks the set after reading the count (line 49) and before changing it (lines 51 to 54), so a bypassed call is not counted as a run. The name is the handler class name, derived as above.

Because the set is static, a bypass holds only inside the transaction whose Apex set it. That makes it a tool for the code that performs the DML: a test’s setup, or a script run around a data load. Nothing in the code reads a custom setting, custom metadata or custom permission, so there is no switch to flip from outside Apex. The worked example below ends with a bypass around an insert.

What does a handler receive instead of the trigger variables?

A ForgeTriggerContext, which its header calls an immutable snapshot of the current trigger context, passed to onTrigger() so handlers never reference Trigger.* directly (ForgeTriggerContext.cls, lines 4 to 5). It has five public final fields (lines 12 to 16): operationType, a System.TriggerOperation value; newRecords and oldRecords, both List<SObject>; and newMap and oldMap, both Map<Id, SObject>. handle() fills them from Trigger.operationType, Trigger.new, Trigger.newMap, Trigger.old and Trigger.oldMap (ForgeTriggerHandler.cls, lines 56 to 58). Salesforce’s page on the trigger context variables says when each is available: new in insert, update and undelete triggers, newMap in before update and the after insert, update and undelete triggers, and old and oldMap in update and delete triggers. A field whose variable is not available in that event (the Context Variable Considerations page’s words) has nothing from the trigger to hold. I expect it to be null, newMap in a before insert included; that is my reading, not the page’s. The code makes the same assumption for oldMap: hasChanged() tests it for null (ForgeTriggerContext.cls, line 62).

Final means the five references cannot be reassigned. The lists are the ones the trigger supplied, though, because the constructor stores what it is given (lines 18 to 26) and handle() gives it Trigger.new itself (ForgeTriggerHandler.cls, line 57). So a value a handler sets on a record in newRecords during a before trigger is the value saved; the same Salesforce page says those records can be modified only in before triggers. The lists are typed List<SObject>, so a handler casts each record to its object type, as the example below does. The context carries nothing else from Trigger: no size, no isBefore or isAfter, no isExecuting. A handler that must branch by event reads operationType.

hasChanged(record, field) answers the usual update question (lines 60 to 68). It returns true when the context has no oldMap (line 62), true when the old map has no record for the record’s Id (lines 64 to 65), and otherwise whether record.get(field) differs from the old record’s value (line 67). On an insert context, therefore, every field counts as changed.

How do I build a context in a test?

Four static factories build a context without DML, and each fixes the operation type (ForgeTriggerContext.cls, lines 28 to 58):

  • forInsert(newRecords) sets BEFORE_INSERT with newRecords only; oldRecords and both maps are null (lines 28 to 33).
  • forUpdate(newRecords, oldRecords) sets BEFORE_UPDATE with both lists and builds both maps with new Map<Id, SObject>(list) (lines 35 to 42), which is why the tests give their records a made-up Id of 001000000000001AAA (ForgeTriggerHandlerTest.cls, lines 120 to 121).
  • forDelete(oldRecords) sets BEFORE_DELETE with oldRecords and oldMap; the new side is null (lines 44 to 50).
  • forUndelete(newRecords) sets AFTER_UNDELETE with newRecords and newMap; the old side is null (lines 52 to 58). There is no before undelete to choose instead: Salesforce’s TriggerOperation enum has seven values, and undelete has only AFTER_UNDELETE.

For any other operation type, the constructor is public (lines 18 to 26), so a test can write new ForgeTriggerContext(System.TriggerOperation.AFTER_INSERT, newRecords, newMap, null, null) directly. The parameter order is newRecords, newMap, oldRecords, oldMap.

A test then calls onTrigger(ctx) on the handler rather than handle(), since handle() reads the trigger variables (ForgeTriggerHandler.cls, lines 56 to 58). That is what onTriggerReceivesContext does (ForgeTriggerHandlerTest.cls, lines 82 to 94), and it can because its handlers are inner classes of the test class (lines 4 and 14). For a handler in its own class, onTrigger() is protected (ForgeTriggerHandler.cls, line 63). A separate test class reaches it only if the override carries @TestVisible, which Salesforce’s TestVisible annotation page describes as letting test methods access private or protected members of another class outside the test class. The other route is to test through DML and let the trigger call handle().

A worked example

A trigger on Account for before insert and before update, with a handler that trims the whitespace around Name when the name has changed. The trigger file holds one line of logic.

// AccountTrigger.trigger, written against API version 66.0
trigger AccountTrigger on Account (before insert, before update) {
    ForgeTriggerDispatcher.run(new AccountNameHandler());
}
// AccountNameHandler.cls, written against API version 66.0
public with sharing class AccountNameHandler extends ForgeTriggerHandler {

    protected override void onTrigger(ForgeTriggerContext ctx) {
        for (SObject record : ctx.newRecords) {
            Account account = (Account) record;
            if (account.Name == null || !ctx.hasChanged(account, Account.Name)) {
                continue;
            }
            account.Name = account.Name.trim();
        }
    }
}

Each file needs a meta file beside it. The repository’s class meta files declare <apiVersion>66.0</apiVersion> and <status>Active</status> inside an ApexClass element (ForgeTriggerHandler.cls-meta.xml). The trigger’s meta file carries the same two elements inside ApexTrigger, the two fields the Metadata API’s ApexTrigger page lists as required.

<?xml version="1.0" encoding="UTF-8"?>
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <status>Active</status>
</ApexTrigger>

On a before insert there is no old version of any record, so hasChanged() is true for every record and every non-null name is trimmed. That rests on ForgeTriggerContext.cls: line 62 returns true when oldMap is null, and lines 64 to 65 when it holds no record for the Id. On a before update it is true only for records whose Name differs from the old version (line 67). The handler runs once per transaction unless it overrides getMaxReEntries(), so a second DML statement on Account in the same transaction skips it. To load Account data from Apex without the handler, bypass it by class name around the statement.

// Written against API version 66.0; accounts is the list to insert
ForgeTriggerHandler.bypass('AccountNameHandler');
insert accounts;
ForgeTriggerHandler.clearBypass('AccountNameHandler');

This example was written against the source at f9bf157 and API version 66.0, and uses nothing the source does not provide. It has not been run in an org.

What do the tests prove?

ForgeTriggerHandlerTest.cls is the one class in the ForgeTriggerHandler suite (ForgeTriggerHandler.testSuite-meta.xml). Two inner handlers serve it: TestHandler counts calls to onTrigger() and records the operation type (lines 4 to 12), and ReEntrantHandler raises the limit to 3 (lines 14 to 24). The twelve test methods, in file order:

  • bypassPreventsExecution (lines 26 to 38): after bypass('TestHandler'), isBypassed is true; after clearBypass, false. Despite the name, nothing executes.
  • clearAllBypasses (lines 40 to 50): two names bypassed, clearAllBypasses() called, both report false.
  • defaultMaxReEntriesIsOne (lines 52 to 58): getMaxReEntries() on a plain handler returns 1.
  • overriddenMaxReEntries (lines 60 to 66): on ReEntrantHandler it returns 3.
  • getRunCountTracksExecutions (lines 68 to 73): getRunCount('TestHandler') is 0 before anything runs. Despite the name, no execution is tracked.
  • resetRunCountsClearsState (lines 75 to 80): after resetRunCounts(), the count is 0.
  • onTriggerReceivesContext (lines 82 to 94): onTrigger() called directly with a forInsert context counts one call and records BEFORE_INSERT.
  • contextOperationTypeIsSet (lines 96 to 104): forInsert sets operationType to BEFORE_INSERT.
  • hasChangedReturnsTrueOnInsert (lines 106 to 115): on a forInsert context, hasChanged is true.
  • hasChangedDetectsFieldChange (lines 117 to 130): forUpdate with the same Id and a changed Name gives true.
  • hasChangedReturnsFalseWhenUnchanged (lines 132 to 145): the same Id and the same Name give false.
  • dispatcherRunsListInOrder (lines 147 to 156): bypasses TestHandler, calls run() with a two-handler list and clears the bypasses. It asserts nothing, so it proves only that the call completes.

What the suite does not prove matters as much. No test asserts on what handle() does. dispatcherRunsListInOrder reaches it through the dispatcher with TestHandler bypassed, but checks no outcome. So the name derivation, the bypass return, the re-entry stop and the count increment (ForgeTriggerHandler.cls, lines 48 to 54) have no test that proves them, and neither does the order the dispatcher runs a list in. Those claims above are read from the code, not from a passing test.

What does the code not do?

  • The run count is per handler class per transaction, not per record, per event or per batch. With the default limit of 1, the first handle() call in a transaction runs and every later one returns (lines 52 to 54). That covers a second DML statement on the same object, and the same handler dispatched from both a before and an after event. It also covers the second and later batches when one statement touches more than 200 records. Salesforce’s trigger context variables page says DML operations that include over 200 records are processed in batches, and the trigger is invoked for each batch. The static variables page adds that static variables are not reset within the multiple trigger invocations for the same Bulk API request. A handler that must run more often overrides getMaxReEntries(), and then nothing else limits it.
  • Counts and bypasses are statics. They reset with the transaction and only Apex in that transaction can set them. There is no custom setting, custom metadata type, custom permission or per-user switch, and no per-object configuration: each trigger file names its handlers in code.
  • The key is a string. A misspelt name in bypass() adds an entry nothing matches, with no error; isBypassed() is the only check (lines 31 to 34). And the key comes from the undocumented format of String.valueOf(this) (line 48), as above.
  • No bypass for everything. clearAllBypasses() clears the set, but nothing bypasses every handler at once and nothing lists the names in the set.
  • The dispatcher only loops. No error handling, no ordering beyond the list and no registry (ForgeTriggerDispatcher.cls, lines 12 to 23).
  • The context is thin. Its lists are List<SObject> and need casting. It has no size, isBefore, isAfter or isExecuting. The four factories cover four operation types and the public constructor the rest. hasChanged() compares with != and treats every field as changed when there is no oldMap, which includes a forUndelete context (ForgeTriggerContext.cls, lines 52 to 58 and 62).
  • Tests go through onTrigger(). handle() reads the trigger variables (ForgeTriggerHandler.cls, lines 56 to 58) and no unit test asserts on the guard in front of them (lines 48 to 54). onTrigger() is protected (line 63), so a top-level handler needs @TestVisible on the override to be called from its test.

References

  • Apex Forge Platform, github.com/WTWatson/apex-forge-platform at commit f9bf157 (21 April 2026): ForgeTriggerHandler.cls, ForgeTriggerDispatcher.cls, ForgeTriggerContext.cls and ForgeTriggerHandlerTest.cls with their meta files under force-app/main/default/classes, and ForgeTriggerHandler.testSuite-meta.xml under force-app/main/default/testSuites.
  • Apex Developer Guide, Static and Instance Methods, Variables, and Initialization Code: static variables are static only within the scope of the Apex transaction, and are not reset within the multiple trigger invocations for the same Bulk API request.
  • Apex Developer Guide, Trigger Context Variables: operationType, new, newMap, old and oldMap, when each is available and that new can be modified only in before triggers. DML operations over 200 records are processed in batches, with the trigger invoked for each batch.
  • Apex Developer Guide, Context Variable Considerations: what each trigger event allows with trigger.new and the original object, and that trigger.new is not available in delete triggers; “not available” is the page’s phrase.
  • Apex Developer Guide, TestVisible Annotation: test methods may access private or protected members of another class.
  • Apex Reference Guide, TriggerOperation Enum, seven values with no BEFORE_UNDELETE, and String Class, valueOf(toConvert).
  • Metadata API Developer Guide, ApexTrigger: apiVersion and status.