Trigger frameworks that get "one trigger per object, delegate to handler classes" right are common. What's usually still wrong is where the control logic lives — execution order, activation state, and bypass logic. Hardcoded execution controls require Apex changes; a single Custom Setting checkbox offers only coarse control.
Behind a clean-looking TriggerDispatcher.run() call, Salesforce needs to resolve which handlers apply to this object and context, in what order, whether each is currently active, and whether the running user should bypass it — all inside the trigger's governor-limit budget, on every single transaction.
1. Architecture Overview
At a high level:
Trigger fires (Account, Opportunity, etc.)
TriggerDispatcher.run(objectName, context)
TriggerHandlerCache.getFor(objectName, context)
Trigger_Handler__mdt.getAll() (zero-SOQL-cost, platform-cached)
Filter: Is_Active__c = true
Filter: Bypass_Permission__c not assigned to running user
Sort by Execution_Order__c
Type.forName(Handler_Class__c).newInstance()
handler.execute(ctx)
Salesforce's own trigger runtime decides when the trigger context fires. This framework only decides what runs, in what order, and whether it runs at all — and every one of those three decisions is data, not code.
2. Why Not Custom Settings
Custom Settings and Custom Metadata Types look interchangeable for configuration, but for execution-control logic specifically, CMDT is the better fit on the axes that matter for execution control:
| Concern | Custom Setting | Custom Metadata Type |
|---|---|---|
| Deployable as metadata (change sets / SFDX / packages) | Definition: Yes (packageable) — Records: No | Yes |
| Query cost inside a trigger | Zero-cost — getInstance() / getAll() are served from platform cache, not SOQL | Zero-cost via getAll() |
| Packageable in managed/unlocked packages | Definition: yes. Records: no — populate separately after installation | Yes |
| Per-record relationship modeling | Limited | Supports metadata relationships |
| Granular bypass (per handler, per context, per permission) | Awkward — usually one blanket flag | Enabled by this schema plus the dispatcher — one record per rule |
Neither getAll() nor Custom Settings' getInstance() costs a SOQL query inside a trigger — both are served from platform cache. The reason to prefer CMDT for execution control is that its records are metadata: they deploy, package, and version-control alongside the Apex they govern, and Section 9's test can block a bad one at deploy time. Custom Setting records are data, not metadata — they don't travel in the deployment; each org is seeded by a separate script or data file (which can itself be version-controlled).
3. Custom Metadata Type Setup
Create Trigger_Handler__mdt with the following fields:
Trigger_Handler__mdt
├── Object_API_Name__c Text(80) — e.g. "Opportunity"
├── Handler_Class__c Text(255) — e.g. "OpportunityBeforeUpdateHandler"
├── Trigger_Context__c Picklist — BEFORE_INSERT, BEFORE_UPDATE, AFTER_INSERT,
│ AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE, AFTER_UNDELETE
├── Execution_Order__c Number(4,1) — decimal, not integer
├── Is_Active__c Checkbox — deploy-free kill switch
├── Bypass_Permission__c Text(80) — optional Custom Permission API name
├── Description__c Long Text — why this handler exists
└── Is_Critical__c Checkbox — re-throw after logging instead of continuing
The Decimal Ordering Trick: Execution_Order__c is deliberately a decimal, not an integer. Two independent points are at work here. First, leave gaps between values (10, 20, 30) so a newly discovered dependency can usually be slotted in — at 15, say — without renumbering every other record. That much works with integers alone. The decimal type adds headroom for the case where the gap is already full: with A at 10 and B at 11, a handler can still be placed between them at 10.5. Renumbering under a release freeze is exactly the kind of change this framework exists to avoid.
Deployment Gotcha — deploymentStatus is invalid on CMDT: Custom Metadata Type object metadata must NOT include a deploymentStatus field — that field only applies to regular Custom Objects. Including it on Trigger_Handler__mdt.object-meta.xml fails the deploy with "Cannot specify: deploymentStatus for Custom Metadata Type". This is usually the very first error anyone hits when standing up this framework — strip the field entirely from the CMDT object metadata before deploying.
Deploying the CMDT Records Themselves
Deploying the type and its fields is only half the job — the actual handler records (one per handler registration) are separate metadata components with their own gotchas.
The <label> Element Trap: Writing the record XML the "obvious" way — <CustomMetadata label="..."> as a root attribute — fails with "Required fields are missing: [MasterLabel]". CMDT records require <label> as a child element, not an attribute of the root tag. The correct shape:
<?xml version="1.0" encoding="UTF-8"?>
<CustomMetadata xmlns="http://soap.sforce.com/2006/04/metadata" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" fullName="Trigger_Handler.Opportunity_BeforeUpdate_Validation">
<label>Opportunity BeforeUpdate Validation</label>
<protected>false</protected>
<values>
<field>Object_API_Name__c</field>
<value xsi:type="xsd:string">Opportunity</value>
</values>
<values>
<field>Trigger_Context__c</field>
<value xsi:type="xsd:string">BEFORE_UPDATE</value>
</values>
<values>
<field>Handler_Class__c</field>
<value xsi:type="xsd:string">OpportunityBeforeUpdateHandler</value>
</values>
<values>
<field>Execution_Order__c</field>
<value xsi:type="xsd:double">10</value>
</values>
<values>
<field>Is_Active__c</field>
<value xsi:type="xsd:boolean">true</value>
</values>
</CustomMetadata>
Deploy Ordering Constraint: Salesforce does support deploying a Custom Metadata Type and its records in one deployment when that deployment also carries the required dependencies. In practice, deploying the brand-new Trigger_Handler__mdt object together with its records in a single pass produced the error below against a genuinely clean org. The workaround that resolved it here was a two-step deploy: (1) deploy Trigger_Handler__mdt and its fields alone, confirm it lands, then (2) deploy the Trigger_Handler__mdt records in a second deployment. The tests for this framework were run against an existing org that already had the type installed, so they do not verify a clean-org installation; treat the two-step sequence as a fix for this specific deployment error, not a general Salesforce constraint. The actual error hit when this happens: "Custom metadata type Trigger_Handler__mdt is not available in this organization."
4. The Handler Interface
Every handler implements a single-method interface so the dispatcher can treat all of them identically:
public interface ITriggerHandler {
void execute(TriggerContext context);
}
public class OpportunityBeforeUpdateHandler implements ITriggerHandler {
public void execute(TriggerContext context) {
// context.newList / context.oldMap etc. — the dispatch passes them in;
// nothing is read off static state.
}
}
Keep the interface minimal — one method. The framework passes trigger data in through a single TriggerContext parameter (shown below); resist adding further methods or per-use-case arguments, which would couple every handler to data it may not need.
Two ways to close this gap were considered:
Static context holder (considered here, not adopted): a small class holds Trigger.new, Trigger.old, Trigger.newMap, and Trigger.oldMap as static fields, refreshed on every TriggerDispatcher.run() call. Handlers read it by reference. ITriggerHandler.execute() took zero arguments in that model.
Widen the interface (the approach this guide now implements): change execute() to execute(TriggerContext ctx) and pass the context explicitly into every handler. More explicit and easier to unit test a handler in isolation, at the direct cost of touching the interface every handler already implements. The static-holder option below is also legitimate — a team that weighs leaving a widely-implemented interface untouched above per-handler explicitness could keep that instead.
The widened TriggerContext — built once per dispatch and passed into every handler:
public class TriggerContext {
public final List<SObject> newList;
public final List<SObject> oldList;
public final Map<Id, SObject> newMap;
public final Map<Id, SObject> oldMap;
public TriggerContext(List<SObject> newList, List<SObject> oldList,
Map<Id, SObject> newMap, Map<Id, SObject> oldMap) {
this.newList = newList; this.oldList = oldList;
this.newMap = newMap; this.oldMap = oldMap;
}
// Built once per TriggerDispatcher.run(). Trigger.isExecuting is false
// (and never throws) outside a trigger, so a direct call yields an empty context.
public static TriggerContext fromTrigger() {
if (!Trigger.isExecuting) return new TriggerContext(null, null, null, null);
return new TriggerContext(
(Trigger.isInsert || Trigger.isUpdate || Trigger.isUndelete) ? Trigger.new : null,
(Trigger.isUpdate || Trigger.isDelete) ? Trigger.old : null,
(Trigger.isUpdate || (Trigger.isAfter && (Trigger.isInsert || Trigger.isUndelete))) ? Trigger.newMap : null,
(Trigger.isUpdate || Trigger.isDelete) ? Trigger.oldMap : null
);
}
}
TriggerDispatcher.run() calls TriggerContext.fromTrigger() once, at the very top, on every single call — not cached like TriggerHandlerCache in Section 5, because a single transaction can re-enter multiple trigger contexts, and each entry needs its own fresh snapshot of Trigger.new and Trigger.oldMap.
A handler receives the context as its argument:
public class OpportunityBeforeUpdateHandler implements ITriggerHandler {
public void execute(TriggerContext context) {
for (SObject rec : context.newList) {
Opportunity newOpp = (Opportunity) rec;
Opportunity oldOpp = (Opportunity) context.oldMap.get(newOpp.Id);
if (newOpp.Name != oldOpp.Name) {
System.debug('Name changed on ' + newOpp.Id);
}
}
}
}
The Guard Logic, and Why It's Not Optional: newMap and oldMap are only valid in specific trigger contexts per Salesforce's documented rules — newMap in before-update, after-insert, after-update, after-undelete; oldMap in before/after update and before/after delete. The four ternaries above encode exactly those rules. These conditions make collection availability explicit. An unavailable collection is null, so handlers must only dereference collections supported by their trigger context.
A Sharp Edge This Introduces: Trigger is null outside of an actual trigger's execution stack, and in the original static-holder design TriggerContext.populate() ran at the top of every TriggerDispatcher.run() call. Calling TriggerDispatcher.run() directly — from a test method, a utility class, anywhere that isn't a real trigger body — threw System.NullPointerException the instant populate() referenced Trigger.isInsert, even inside a guarding condition. The instance-based fromTrigger() checks Trigger.isExecuting (which never throws) rather than dereferencing Trigger.isInsert, so it safely returns an empty context outside trigger execution. Dispatching handlers that require populated trigger collections can still fail; test those handlers with a constructed context or through DML. See Section 9 and Section 11.
5. Building the Handler Cache
The cache is built once per transaction from getAll(), then reused for every subsequent lookup in the same execution:
public class TriggerHandlerCache {
private static Map<String, List<Trigger_Handler__mdt>> cacheByKey;
public static List<Trigger_Handler__mdt> getFor(String objectName, String context) {
if (cacheByKey == null) {
cacheByKey = new Map<String, List<Trigger_Handler__mdt>>();
for (Trigger_Handler__mdt h : Trigger_Handler__mdt.getAll().values()) {
String key = h.Object_API_Name__c + '.' + h.Trigger_Context__c;
if (!cacheByKey.containsKey(key)) {
cacheByKey.put(key, new List<Trigger_Handler__mdt>());
}
cacheByKey.get(key).add(h);
}
for (List<Trigger_Handler__mdt> bucket : cacheByKey.values()) {
bucket.sort(new HandlerOrderComparator());
}
}
String lookupKey = objectName + '.' + context;
return cacheByKey.containsKey(lookupKey)
? cacheByKey.get(lookupKey)
: new List<Trigger_Handler__mdt>();
}
private class HandlerOrderComparator implements System.Comparator<Trigger_Handler__mdt> {
public Integer compare(Trigger_Handler__mdt a, Trigger_Handler__mdt b) {
Decimal diff = (a.Execution_Order__c == null ? 0 : a.Execution_Order__c) -
(b.Execution_Order__c == null ? 0 : b.Execution_Order__c);
if (diff > 0) return 1;
if (diff < 0) return -1;
String devA = a.DeveloperName == null ? '' : a.DeveloperName;
String devB = b.DeveloperName == null ? '' : b.DeveloperName;
if (devA == devB) return 0;
return devA < devB ? -1 : 1;
}
}
}
HandlerOrderComparator, defined: the sort above referenced a comparator that wasn't shown — without it, TriggerHandlerCache doesn't compile. It's a plain null-safe decimal comparator; nothing clever, but easy to forget to include when writing this up. Execution_Order__c ties alone aren't enough, though: List.sort() in Apex isn't guaranteed stable, so two records tied on Execution_Order__c could silently swap places between cache rebuilds. DeveloperName is used as a deterministic secondary sort key to close that gap.
Why static, not per-call: Trigger_Handler__mdt.getAll() is cheap relative to SOQL, but there is no reason to rebuild and re-sort the map more than once per transaction. A static variable scoped to the transaction is the correct lifecycle here — it resets naturally between transactions without any explicit cleanup.
6. The Dispatcher
The dispatcher's only responsibility is to walk the cached, sorted list and apply the two runtime filters — active state and bypass permission. Two things were added after the fact and aren't optional: a recursion guard (activeKeys) that blocks re-entrant calls into the same object/context in one transaction, and a finally block that clears the guard key regardless of how the loop exits, including while a critical handler's exception propagates out. With the widened interface there is no TriggerContext snapshot to restore — each run() owns its own context instance — so releasing the guard key is all the finally block now does. See Section 10.
public class TriggerDispatcher {
private static Set<String> activeKeys = new Set<String>();
public static void run(String objectName, String context) {
String key = objectName + '.' + context;
if (activeKeys.contains(key)) {
TriggerErrorLogger.log(objectName, context, null,
new RecursionGuardException('Recursive re-entry into ' + key + ' blocked.'));
return;
}
activeKeys.add(key);
TriggerContext ctx = TriggerContext.fromTrigger();
try {
for (Trigger_Handler__mdt h : TriggerHandlerCache.getFor(objectName, context)) {
if (!h.Is_Active__c) continue;
if (String.isNotBlank(h.Bypass_Permission__c)
&& FeatureManagement.checkPermission(h.Bypass_Permission__c)) continue;
try {
// A fresh instance per handler per dispatch — cheap relative to
// everything else in a trigger, and handlers don't have to be
// written to a "no instance state" contract.
ITriggerHandler handler = (ITriggerHandler) Type.forName(h.Handler_Class__c).newInstance();
handler.execute(ctx);
} catch (Exception e) {
TriggerErrorLogger.log(objectName, context, h.Handler_Class__c, e);
if (h.Is_Critical__c) throw e;
}
}
} finally {
activeKeys.remove(key); // no context restore — ctx was local to this call
}
}
public class RecursionGuardException extends Exception {}
}
Each object's trigger becomes a one-liner:
trigger OpportunityTrigger on Opportunity (
before insert, before update, after insert, after update
) {
TriggerDispatcher.run('Opportunity', TriggerContextResolver.current());
}
7. Trigger Context Resolution
TriggerContextResolver.current() returns Trigger.operationType.name() — the platform's System.TriggerOperation enum already names all seven contexts (BEFORE_INSERT, AFTER_UPDATE, ...). The Trigger_Context__c picklist values are set to those exact names, so there is no hand-written mapping left to drift:
public class TriggerContextResolver {
public static String current() {
return Trigger.operationType != null ? Trigger.operationType.name() : null;
}
}
Picklist Drift Risk: Because current() delegates to the platform enum, there is no branch here to fall out of sync. The inverse risk remains: a Trigger_Context__c picklist value that isn't an exact System.TriggerOperation name (e.g. "AfterUpdate" instead of "AFTER_UPDATE") silently dispatches nothing — no error, no log. The validation test in Section 9 asserts every Trigger_Context__c value is a real System.TriggerOperation name, so a drifted value fails the deploy instead.
8. Per-Handler Bypass via Custom Permission
A single blanket bypass flag can't express "skip validation-heavy handlers during a data load, but keep sharing-recalculation handlers running." Mapping Bypass_Permission__c to a Custom Permission solves this at the correct granularity:
Integration User Permission Set
Assigned Custom Permission: Bypass_Opportunity_Validation
Trigger_Handler__mdt.Bypass_Permission__c = 'Bypass_Opportunity_Validation'
FeatureManagement.checkPermission('Bypass_Opportunity_Validation')
True for this user → handler skipped
False for this user → handler runs normally
This lets a data-load integration user skip exactly the handlers named for bypass, while every other user — and every other handler on the same object — continues executing normally.
9. Closing the Dynamic Apex Safety Gap
Type.forName(...).newInstance() trades compile-time safety for runtime flexibility. A typo in Handler_Class__c, or a handler class renamed without updating its CMDT record, fails at runtime — the first time that trigger context fires after the bad record is deployed — not at deploy time.
The Fix: a pair of Apex tests that iterate every Trigger_Handler__mdt record and assert (a) the referenced class resolves, instantiates, and implements ITriggerHandler, and (b) its Object_API_Name__c is a real SObject and its Trigger_Context__c is a valid System.TriggerOperation name. This converts a silent runtime gap into a deployment-blocking test failure, which is exactly where the failure should surface.
@IsTest
private class TriggerHandlerCMDTValidationTest {
@IsTest
static void allHandlerClassesResolve() {
for (Trigger_Handler__mdt h : Trigger_Handler__mdt.getAll().values()) {
Type t = Type.forName(h.Handler_Class__c);
System.assertNotEquals(
null, t,
'Trigger_Handler__mdt record "' + h.DeveloperName +
'" references a class that does not exist: ' + h.Handler_Class__c
);
// Type.forName only proves the class exists - it says nothing about
// whether it can be instantiated or actually implements ITriggerHandler.
// Both are checked explicitly so a class that exists but doesn't fit
// the contract fails here, not at dispatch time in production.
Object instance;
try {
instance = t.newInstance();
} catch (Exception e) {
instance = null;
System.assert(
false,
'Trigger_Handler__mdt record "' + h.DeveloperName +
'" references a class that cannot be instantiated: ' + h.Handler_Class__c +
' (' + e.getMessage() + ')'
);
}
System.assert(
instance instanceof ITriggerHandler,
'Trigger_Handler__mdt record "' + h.DeveloperName +
'" references a class that does not implement ITriggerHandler: ' + h.Handler_Class__c
);
}
}
// Object_API_Name__c and Trigger_Context__c are the other two columns the
// dispatcher keys on. A bad object name or a Trigger_Context__c that isn't a
// System.TriggerOperation name both fail the same silent way in production:
// the handler never runs, no error, no log (Section 7 drift risk).
@IsTest
static void allCmdtTargetsAreValid() {
Set<String> validContexts = new Set<String>();
for (System.TriggerOperation op : System.TriggerOperation.values()) {
validContexts.add(op.name());
}
for (Trigger_Handler__mdt h : Trigger_Handler__mdt.getAll().values()) {
try {
Schema.describeSObjects(new List<String>{ h.Object_API_Name__c });
} catch (Exception e) {
System.assert(
false,
'Trigger_Handler__mdt record "' + h.DeveloperName +
'" references an object that does not exist: ' + h.Object_API_Name__c
);
}
System.assert(
validContexts.contains(h.Trigger_Context__c),
'Trigger_Handler__mdt record "' + h.DeveloperName +
'" has Trigger_Context__c="' + h.Trigger_Context__c +
'" which is not a System.TriggerOperation name (e.g. BEFORE_INSERT, AFTER_UPDATE)'
);
}
}
}
This test is what makes the dynamic-instantiation trade-off acceptable in a production framework. Without it, a bad CMDT record is a silent landmine; with it, the same mistake fails CI before it ever reaches an org. Read the wider suite conservatively, though: the passing tests exercised nested-context preservation, recursion blocking, Execution_Order__c tie-breaking, non-critical continuation, CMDT configuration validity, and end-to-end execution of the Account handlers across insert, update, delete, and undelete. They do not establish every handler's business behavior — a test that performs DML and asserts nothing, or only asserts System.assert(true), shows the transaction completed, not that any handler produced its intended effect. Per-handler outcome assertions, plus explicit bypass, deactivation, bulk-processing, and critical-error-propagation scenarios, remain useful follow-ups.
A Testing Gotcha Introduced by TriggerContext (Section 4): in the original static-holder design, once TriggerContext.populate() was wired into TriggerDispatcher.run(), any test that called TriggerDispatcher.run() directly — bypassing a real trigger — used to throw System.NullPointerException, because Trigger is null outside an actual trigger execution. With the widened interface (Section 4) the dispatcher builds context via TriggerContext.fromTrigger(), which checks Trigger.isExecuting and safely returns an empty context outside trigger execution. Dispatching handlers that require populated trigger collections can still fail: a handler that dereferences ctx.newList NPEs against an empty context. A test written as TriggerDispatcher.run('Opportunity', 'BEFORE_UPDATE') to check dispatch behavior in isolation still fails the moment a handler on that path dereferences its null collections; to exercise real handler logic end to end, construct a context or test through DML:
@IsTest
static void dispatcherRunsActiveHandlerForMatchingContext() {
Opportunity o = new Opportunity(Name = 'Dispatcher Test Opp', StageName = 'Prospecting', CloseDate = Date.today().addDays(30));
insert o;
Test.startTest();
o.Name = 'Dispatcher Test Opp - Updated';
update o;
Test.stopTest();
}
A test that only needs to check the CMDT lookup itself — not the full handler execution — should call TriggerHandlerCache.getFor() directly instead, since that method never touches Trigger.*:
@IsTest
static void cacheReturnsEmptyListForUnknownContext() {
List<Trigger_Handler__mdt> handlers = TriggerHandlerCache.getFor('Opportunity', 'SomeContextThatDoesNotExist');
System.assertEquals(0, handlers.size());
}
10. Error Handling
A single misbehaving handler should not be able to take down every other handler registered for the same object and context.
The Isolation Rule: Wrap handler.execute(ctx) in a try/catch that logs the failure rather than letting one broken handler propagate an unhandled exception through the entire trigger context. As deployed, TriggerErrorLogger.log() simply calls System.debug — acceptable for this guide as long as that is stated plainly. Durable failure logging is an extension: a synchronous custom-object insert or an enqueued job is still transaction-dependent and rolls back with the transaction, whereas a Platform Event published with the Publish Immediately behavior survives a publisher rollback and is the option to reach for when the failure record must outlive a failed transaction.
for (Trigger_Handler__mdt h : TriggerHandlerCache.getFor(objectName, context)) {
if (!h.Is_Active__c) continue;
if (String.isNotBlank(h.Bypass_Permission__c)
&& FeatureManagement.checkPermission(h.Bypass_Permission__c)) continue;
try {
ITriggerHandler handler = (ITriggerHandler) Type.forName(h.Handler_Class__c).newInstance();
handler.execute(ctx);
} catch (Exception e) {
TriggerErrorLogger.log(objectName, context, h.Handler_Class__c, e);
if (h.Is_Critical__c) {
throw e;
}
}
}
TriggerErrorLogger is the one class this guide calls without defining. A minimal System.debug implementation is enough to run everything here; durable logging (above) stays an optional extension:
public class TriggerErrorLogger {
public static void log(String objectName, String context, String handlerClass, Exception e) {
System.debug(LoggingLevel.ERROR, 'Trigger handler error [' + objectName + '.' + context + '] ' + handlerClass + ': ' + e.getMessage());
}
}
Whether to re-throw or continue is a business decision per handler, not a framework default — a validation handler that fails probably should block the transaction; a non-critical notification handler probably shouldn't. That decision is captured on the CMDT record itself via Is_Critical__c — a checkbox per handler that flips the catch block from "log and continue" to "log and re-throw," so the choice lives in configuration alongside execution order and bypass, not buried in a comment.
What This Isolation Doesn't Cover: the try/catch isolates one handler's exception from its neighbors, not the shared transaction state or governor limits — every handler on a given trigger invocation still draws from the same SOQL/DML/CPU budget. Confirmed empirically: a handler that exceeds a governor limit (e.g. issuing a 101st synchronous SOQL query) throws a System.LimitException that escapes this catch block entirely — the transaction fails uncaught, exactly as if there were no try/catch at all. Exception isolation only helps for ordinary exceptions a handler's own logic might throw; it is not a safety net against limit exhaustion. Swallowing an exception is risky in both before- and after-context handlers: a before-context handler can itself perform DML on other records, and an after-context handler runs after earlier handlers that may already have issued DML. If the transaction commits, changes made before the exception can persist even though the failed handler did not finish; subsequent handlers continue running. That is why log-and-continue should be the exception, not the default.
11. Common Problems
Handler runs in the wrong order despite a correct Execution_Order__c value
Cause: stale cache is one possible explanation — TriggerHandlerCache was built earlier in the same transaction (for example by another synchronous trigger invocation within the same transaction) and the later dispatch reused that stale map. A chained Queueable is not an example of this: it executes in a separate transaction and does not reuse the originating transaction's static cache. Metadata changes take effect on the next transaction, not mid-transaction — expected CMDT caching behavior, not a bug. Unexpected ordering can also come from a wrong Execution_Order__c value or the DeveloperName tie-break, so confirm those too.
Handler silently never fires
Cause: Trigger_Context__c value doesn't exactly match what TriggerContextResolver.current() returns (see Section 7's picklist drift risk), or Is_Active__c is unchecked.
Bypass isn't taking effect for an integration user
Cause: the Custom Permission is assigned to a Permission Set, but the Permission Set isn't assigned to the integration user — or the user is affected by a different handler's Bypass_Permission__c value that wasn't updated to match.
Runtime Type.forName returns null
Cause: handler class renamed or deleted without updating the CMDT record. This should already be caught by the validation test in Section 9 — if it reaches production, the test either wasn't run or wasn't included in the deployment's test run.
Calling TriggerDispatcher.run() directly (narrowed, not eliminated, by the widened interface)
In the original static-holder design, TriggerContext.populate() read Trigger.isInsert / Trigger.isUpdate / etc. directly, and Trigger is null outside a real trigger's execution stack, so any call to TriggerDispatcher.run() not reached through an actual trigger threw immediately. The widened interface (Section 4) replaced populate() with TriggerContext.fromTrigger(), which guards on Trigger.isExecuting so it safely returns an empty context outside trigger execution. Dispatching handlers that require populated trigger collections can still fail: a handler that assumes a populated ctx will NPE, so test those handlers with a constructed context or through DML.
A recursive trigger invocation silently does nothing on re-entry
Cause: TriggerDispatcher's activeKeys guard blocks a second call into the same object/context (e.g. an Opportunity After Update handler that triggers another Opportunity DML in the same transaction) and logs it as a RecursionGuardException rather than running the handlers again. This is deliberate. The guard is scoped to the current call stack — added on entry to run(), removed in its finally block — so a Workflow- or Flow-driven field update that re-fires the trigger is only allowed through if the original run() has already returned and removed the key; if it re-fires while that key is still active, the guard blocks it too. What it blocks is every nested invocation into the same object/context while the outer run() is still on the stack — even when the nested call carries entirely different records. Handler-level processed-Id tracking does not solve this, because the dispatcher returns at the guard check before any handler on the re-entrant pass is reached. Legitimate nested processing of the same object/context requires adjusting the dispatcher's recursion policy (for example keying the guard on record Ids, or allowing a bounded re-entry depth) rather than working around it in a handler.
12. Custom Settings vs. Custom Metadata Types — Where Each Belongs
This isn't an argument to eliminate Custom Settings from the org. The distinction is ownership and lifecycle:
Custom Setting — appropriate for:
Simple, user- or profile-specific configuration flags that don't need packaging or deployment portability
Org-wide defaults that business users, not developers, should edit directly in Setup
Custom Metadata Type — appropriate for:
Execution control logic (order, activation, bypass) that belongs in the same governance and deployment pipeline as the code it controls
Anything that should be packageable, environment-portable, or covered by a deployment-blocking test
13. Production Recommendations
Keep the ITriggerHandler interface to a single method — execute(TriggerContext ctx). Passing the context in explicitly (Section 4) makes a handler easy to unit-test in isolation; resist adding further methods per use case.
Use decimal Execution_Order__c values with gaps (10, 20, 30) so insertions don't require renumbering.
Ship the CMDT validation test (Section 9) in the same deployment as the framework itself — never add it later.
Isolate handler exceptions (Section 10) so one broken handler doesn't take down unrelated handlers on the same object — but default Is_Critical__c to true (log and re-throw) unless a handler is proven free of side effects, so a swallowed exception can't leave a handler's pre-failure DML committed while that handler never finished. Push genuinely non-critical work to a Queueable or Platform Event.
Map bypass logic to Custom Permissions, never to a single blanket Custom Setting checkbox.
Document why each handler exists in Description__c — six months later, this replaces commit-history archaeology.
Set the Trigger_Context__c picklist values to exactly match the System.TriggerOperation enum names (BEFORE_INSERT, AFTER_UPDATE, ...). TriggerContextResolver just returns Trigger.operationType.name(), so a picklist value that doesn't match a live enum name silently dispatches nothing.
Test reordering, deactivation, and bypass as explicit scenarios, not just handler logic in isolation.
TriggerContext.fromTrigger() (Section 4) safely returns an empty context outside trigger execution, but dispatching handlers that need populated trigger collections can still fail — test those with a constructed context or through DML, and in normal operation invoke run() only from a trigger body.
Regression or demo handlers registered active in CMDT must self-gate with if (!Test.isRunningTest()) return; as the first line of execute() — otherwise every real transaction runs them too (inserting probe records, throwing, re-dispatching). Handlers that only debug are harmless and need no guard.
Beyond the recommendations above, a few further extensions are worth noting but are not prerequisites for a first write-up: handler-specific idempotency guards, injectable configuration so handlers can be tested in isolation without CMDT, durable failure logging (Section 10), and stronger bulk and bypass assertions in the test suite.
14. Complete End-to-End Flow
Trigger fires
TriggerDispatcher.run(objectName, TriggerContextResolver.current())
TriggerContext.fromTrigger() — a fresh instance on every call, not cached like TriggerHandlerCache (Section 4)
TriggerHandlerCache.getFor(objectName, context) — built once per transaction from getAll()
For each Trigger_Handler__mdt record, sorted by Execution_Order__c:
├─ Is_Active__c = false → skip
├─ Bypass_Permission__c assigned to running user → skip
└─ Otherwise → Type.forName(Handler_Class__c).newInstance().execute(ctx)
├─ Success → continue to next handler
└─ Exception → TriggerErrorLogger.log(...) → continue or re-throw per handler criticality
Transaction completes
15. Worked Example: Generalizing to a Second Object (Account)
Every example in this guide so far uses Opportunity. That's a reasonable teaching choice, but it leaves the framework's central claim — that adding an object requires zero framework-code changes — untested on paper. This section closes that gap with a second object worked end-to-end.
Account was extended with 7 handlers, one CMDT record per trigger context, verified against real insert, update, delete, and undelete operations in a Developer Edition org:
Trigger_Handler__mdt
├── Object_API_Name__c Text(80) — e.g. "Opportunity"
├── Handler_Class__c Text(255) — e.g. "OpportunityBeforeUpdateHandler"
├── Trigger_Context__c Picklist — BEFORE_INSERT, BEFORE_UPDATE, AFTER_INSERT,
│ AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE, AFTER_UNDELETE
├── Execution_Order__c Number(4,1) — decimal, not integer
├── Is_Active__c Checkbox — deploy-free kill switch
├── Bypass_Permission__c Text(80) — optional Custom Permission API name
├── Description__c Long Text — why this handler exists
└── Is_Critical__c Checkbox — re-throw after logging instead of continuing
Trigger_Handler__mdt records for Account
├─ Account.BEFORE_INSERT → AccountBeforeInsertHandler (Execution_Order__c = 10)
├─ Account.BEFORE_UPDATE → AccountBeforeUpdateHandler (Execution_Order__c = 10)
├─ Account.AFTER_INSERT → AccountAfterInsertHandler (Execution_Order__c = 10)
├─ Account.AFTER_UPDATE → AccountAfterUpdateHandler (Execution_Order__c = 10)
├─ Account.BEFORE_DELETE → AccountBeforeDeleteHandler (Execution_Order__c = 10)
├─ Account.AFTER_DELETE → AccountAfterDeleteHandler (Execution_Order__c = 10)
└─ Account.AFTER_UNDELETE → AccountAfterUndeleteHandler (Execution_Order__c = 10)
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
TriggerDispatcher.run('Account', TriggerContextResolver.current());
}
Result: all seven contexts fired correctly against real DML on Account, with TriggerDispatcher, TriggerHandlerCache, and TriggerContextResolver completely untouched. The only additions were the AccountTrigger one-liner, seven handler classes, and seven CMDT records — which is precisely the thesis of this guide: the framework's code changes rarely, and extending it to a new object is a configuration and handler-authoring exercise, not a framework change.
Conclusion
The core model is:
Trigger Context
Custom Metadata Type Lookup (order, activation, bypass)
Dynamic Handler Instantiation
Exception-Isolated Dispatch
Configuration-Driven Behavior — existing order, activation, and bypass change without Apex
Moving execution order, activation, and bypass logic out of Apex and Custom Settings and into Custom Metadata Types doesn't change what a trigger framework does — it changes when a change to that behavior requires a deployment. The framework's code should change rarely. Existing handler order, activation, and bypass configuration can be changed without changing Apex; adding a new handler class or a trigger on a new object still requires a deployment. And a manually edited CMDT record does not automatically run the Section 9 validation tests — the deployment-blocking protection only applies on a deployment that actually runs them.