Platform
Apex trigger frameworks: one trigger per object, and the recursion guard that holds
Two triggers on the same object fire in an order Salesforce does not promise, and a static boolean recursion flag silently skips half a data load. This is the handler pattern, the Set<Id> guard that survives bulk, the three frameworks worth copying, and the bypass switch your agent writes will need.
A RevOps admin loads 5,000 accounts through Data Loader. About 4,800 come back missing the field your trigger was supposed to stamp. No error, no failed rows, just 200 accounts that worked and the rest that silently didn’t.
The code passed review. It worked on one record in the sandbox for months. The cause is almost always a recursion guard written as a single static boolean, and it is the clearest reason to care how your trigger layer is built.
My rule is short: one trigger per object, a handler class behind it, and a recursion guard keyed on record IDs rather than a boolean. Get those three right and the object scales. Get any one wrong and you ship the bug above.
The trigger layer is where everything collides. Record-triggered flows, Apex, validation rules, duplicate rules, roll-up summaries, managed packages, and now Agentforce actions that write records all run inside one transaction, in a sequence most teams never read. A trigger framework is how you take control of that sequence.
Two triggers on one object is a coin toss
Salesforce is explicit: when you have more than one trigger on the same object for the same event, the order of execution isn’t guaranteed. Two before-insert triggers on Account can fire in either order, and nothing lets you pin it. The order can even change between releases.
So the first rule is mechanical: one trigger per object, and that trigger holds no logic. Everything it does is hand off to a handler class you control. The reasons are order (you decide what runs first, in code), testability (logic in a class is callable from a test without a DML event), and reuse (two objects can share a service method).
Salesforce developers settled on this class-per-object approach more than a decade ago, and it has not been improved on.
Before you write any of it, read the order of execution once, end to end. Before-save flows run before your before triggers. Validation and duplicate rules run after before triggers. The record saves but does not commit, then after triggers run, then assignment, auto-response, and workflow rules.
A workflow field update re-fires your before and after update triggers one more time. After-save flows, roll-up summaries, and criteria-based sharing follow, and only then does the transaction commit. Async work, including Queueable and @future, runs after the commit. Every design decision below comes back to that list.
The handler is a switch on the operation
The trigger itself is two lines. It constructs the handler and runs it.
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
new AccountTriggerHandler().run();
}
The handler reads the context and routes. The cleanest router is a switch on Trigger.operationType, the enum Salesforce added for exactly this. It pairs the before/after and the DML type into one value, so you stop writing nested if (Trigger.isBefore && Trigger.isInsert) ladders.
public with sharing class AccountTriggerHandler {
public void run() {
switch on Trigger.operationType {
when BEFORE_INSERT { onBeforeInsert(Trigger.new); }
when BEFORE_UPDATE { onBeforeUpdate(Trigger.new, (Map<Id, Account>) Trigger.oldMap); }
when AFTER_INSERT { onAfterInsert(Trigger.new); }
when AFTER_UPDATE { onAfterUpdate(Trigger.new, (Map<Id, Account>) Trigger.oldMap); }
when else { /* delete, undelete, as needed */ }
}
}
private void onBeforeInsert(List<Account> records) {
// stamp defaults, normalise fields, no SOQL or DML in a loop
}
private void onAfterUpdate(List<Account> records, Map<Id, Account> old) {
// cross-object work, collected and written once
}
// ...
}
Keep the handler methods thin. Their job is to decide what work to do and hand a collection to a service or domain class that does it.
Which context variable you reach for matters, and getting it wrong is a common null-pointer. Trigger.old is null on insert, Trigger.newMap is null in before insert because the records have no IDs yet, and in after triggers Trigger.new is read-only, so field edits belong in a before context. The context variable reference has the full availability matrix.
Everything in the handler has to assume a batch of up to 200 records, because Data Loader and the Bulk API hand your trigger records 200 at a time. A single SOQL query inside a per-record loop becomes 200 queries and dies at the synchronous ceiling of 100. Query once with an IN clause into a map, work in memory, write once.
The recursion guard that survives a data load
The silent-skip bug lives in recursion control. Triggers re-enter within a single transaction: a workflow field update re-fires your update trigger, and any DML your handler does on records that flow back through the same trigger re-enters it too. You need a guard so the second pass doesn’t repeat the first.
The guard everyone writes first is a static boolean:
public class AccountTriggerHandler {
public static Boolean hasRun = false;
public void run() {
if (hasRun) return;
hasRun = true;
// ... do the work
}
}
Static variables live for the length of one transaction and reset between transactions, which is why they work for recursion at all. But this version breaks on a bulk load, and it breaks silently.
Load 5,000 records and the platform invokes your trigger 25 times, once per 200-record chunk, inside one transaction. The first chunk sets hasRun = true. Chunks two through twenty-five see the flag and return immediately. Records 201 and up never get processed. Those are the 4,800 missing accounts.
The flag conflated two different questions: “has this code run in this transaction” and “has this specific record been processed.” You want the second one. So track IDs, not a boolean:
public class AccountTriggerHandler {
private static Set<Id> processed = new Set<Id>();
public void run() {
List<Account> toProcess = new List<Account>();
for (Account a : (List<Account>) Trigger.new) {
if (!processed.contains(a.Id)) {
toProcess.add(a);
processed.add(a.Id);
}
}
if (toProcess.isEmpty()) return;
// ... process only the records not seen before
}
}
Every record gets processed exactly once, across every chunk, and a genuine re-entry on the same record is still blocked. Nebula’s write-up on why static flags fail is the clearest treatment if you want the full argument. The frameworks below generalise the idea with a max-loop-count that throws once a record cycles too many times, instead of dropping it in silence.
Copy a framework, or write the twenty lines
You do not have to invent this. Three open-source frameworks have solved it, and they represent three philosophies.
- Kevin O’Hara’s sfdc-trigger-framework is the minimal one. A single virtual
TriggerHandlerclass; your handler extends it and overridesbeforeInsert(),afterUpdate(), and the rest. It gives yousetMaxLoopCount(n)for recursion and staticbypass('AccountTriggerHandler')/clearBypass(...)for the kill switch. If you want a pattern you can read in one sitting, start here. - fflib-apex-common puts triggers inside the Enterprise Patterns. Your logic lives in a Domain class that extends
fflib_SObjectDomainand overridesonBeforeInsert(),onAfterUpdate(), andonValidate(), alongside Selector and Unit of Work layers. Heavier, and it earns that weight on large orgs with real separation-of-concerns needs, not on a five-object build. - Mitch Spano’s Trigger Actions Framework is metadata-driven. You register each action as a Custom Metadata record and set its order and on/off state there, so changing the sequence or disabling a step is a config change, not a deploy. It orchestrates Flow and Apex actions under one order, which the other two don’t.
Which to use is a question of scale. On a small or mid-size org, O’Hara’s framework or twenty lines of your own is plenty, and the metadata framework’s config surface is overhead you won’t use. On a large org with many teams writing to the same objects, the metadata-driven ordering and the Domain layer start paying for themselves. Don’t adopt fflib because it’s respected; adopt it when you feel the pain it removes.
The bypass switch you will wish you had
Every framework above ships a bypass mechanism, and it is the feature that looks optional until the day it isn’t. A bypass lets a specific user, or a specific process, skip a handler without a code change.
You reach for it in three situations: a one-time data migration that should not re-fire enrichment logic, an integration user whose writes should skip the validation a human needs, and an Agentforce agent whose action writes a record your trigger would otherwise block.
The strongest version is driven by a Custom Permission or Custom Metadata, so you toggle it per user or globally with no deploy. Mitch Spano’s framework supports bypass by permission, by a flag on the metadata record, and programmatically in a transaction. O’Hara’s does it with the static bypass() call. A named, auditable switch beats commenting out a validation rule at 11pm during a migration and forgetting to put it back.
This connects to agent reliability. An Agentforce action built on Apex or Flow performs ordinary DML, so it runs the full order of execution: your triggers, validation rules, and duplicate rules all fire on the agent’s write.
The action runs as a real user, the submitting user for an employee-facing agent and the agent’s own identity for a service agent, so it is subject to that user’s sharing and any validation rule that user can’t satisfy. A recursion bug or an un-bypassable validation doesn’t read as a trigger problem to the business. It reads as the AI being broken.
Flow or Apex for the trigger itself
The “one trigger per object” rule has a bigger sibling: one automation tool per object. Mixing an Apex trigger, a record-triggered flow, and a leftover Process Builder on the same object recreates the exact order problem you were trying to kill, because now the uncontrolled sequence is across tools.
Salesforce’s record-triggered automation guidance frames the choice by density. Low automation density on an object, reach for a record-triggered Flow. Medium density, a Flow augmented with invocable Apex. High density, with heavy dependencies and real volume, Apex triggers.
Before-save flows update fields on the triggering record cheaply, the Flow analogue of a before trigger. After-save flows handle cross-object work. Workflow Rules and Process Builder are retired for new automation, and the Migrate to Flow tool exists to move the old ones, so new work is Flow or Apex, nothing else.
My split: Flow for the straightforward, admin-ownable automation, Apex the moment the logic is dense, bulk-sensitive, needs precise ordering, or has to be unit-tested against a spec. The decision guide goes deeper on where that line sits.
Test the trigger like production will
A trigger that passes a one-record test and fails a 200-record load is the default failure mode, so test the bulk path explicitly. Insert at least 200 records in one DML and assert the result, which is the only way to catch a SOQL-in-a-loop that a single record hides.
Use @TestSetup to build shared data once per class, ideally through a test-data factory rather than copy-pasted setup. Wrap the operation under test in Test.startTest() and Test.stopTest() to get a clean set of limits and to force queued async work to finish before you assert.
Then assert the side effect, the field that got stamped or the related record that got created, with an exact expected value computed from the setup data. A test with coverage and no assertions proves nothing. The testing fundamentals cover the rest of the discipline.
Where this lands
The trigger layer is unglamorous, and it decides whether your org is safe to change. A thin trigger, a handler that switches on the operation, a recursion guard keyed on IDs, a named bypass, and one automation tool per object are the whole discipline.
None of it is new, and all of it is still routinely skipped, which is why the silent-skip data load keeps happening to teams that thought their code was fine. Pick a framework or write the twenty lines, but write them the same way on every object, so the next person, or the next agent, meets a layer that behaves.
Understanding the basics
Why should there be only one trigger per object?
Because Salesforce does not guarantee the order multiple triggers on the same object run in. If two triggers handle the same event, either can fire first, and the order can change between releases. Consolidating to one trigger per object, with all logic in a handler class, lets you control the sequence in code and makes the logic testable and reusable.
What is an Apex trigger framework?
A small, reusable structure that moves logic out of the trigger and into a handler class with a consistent shape, usually a virtual base class your handlers extend or a metadata-driven dispatcher. It gives you ordered execution, recursion control, and a bypass switch in one place. Common ones are Kevin O’Hara’s sfdc-trigger-framework, fflib-apex-common, and Mitch Spano’s metadata-driven Trigger Actions Framework.
How do I stop a trigger from running twice?
Track which records you have already processed in a static Set<Id> and skip the ones already in the set. A single static boolean is the usual first attempt, but it breaks on bulk loads: once the first 200-record chunk flips it true, every later chunk is skipped and records go unprocessed. Frameworks handle this with a maximum loop count that throws instead of skipping records in silence.
Should I use Flow or Apex for record-triggered automation?
Follow density. Low automation on an object favours a record-triggered Flow; medium favours Flow plus invocable Apex; high, with heavy dependencies and volume, favours Apex triggers. Keep one automation tool per object either way, and remember Workflow Rules and Process Builder are retired for new automation.
Straightening out an object where three automations fight each other, or making your trigger layer safe for agent writes and bulk loads? Talk to us. Turning a tangled org into one that is cheap to change again is the work we do.