Article
2026-08-19
12 min read
There is a moment in every Salesforce org’s life when the triggers start to feel like a problem. Maybe it is the third time someone copy-pastes a SOQL query into a beforeUpdate block. Maybe it is the day a data load fires six triggers in sequence and blows through governor limits before anyone can figure out which one did it. Maybe it is just the look on a new developer’s face when they open a trigger file and find 400 lines of tangled logic with no clear entry point.
I have worked inside an org with over 400 triggers and 2,200 Apex classes. What I am about to walk you through is not a theoretical framework. It is the system that keeps that org running, and it has held up for years across dozens of developers and thousands of deployments.
The Problem with “Just Put It in the Trigger”
When Salesforce developers are getting started, the trigger file feels like the natural home for business logic. A record gets inserted, you want something to happen, so you write the code right there. It works. It deploys. Everyone moves on.
Then the org grows.
Suddenly you have five developers touching the same trigger file. One person adds a SOQL query inside a for loop because they only tested with one record. Another adds a callout that works fine in isolation but fails when a batch job fires the trigger with 200 records. A third developer adds a beforeUpdate block that quietly conflicts with someone else’s afterUpdate logic.
The real damage is not one catastrophic failure. It is the slow accumulation of fragility. Every change becomes risky because no one fully understands what the trigger does anymore. Code reviews turn into archaeology. Testing becomes guesswork. Deployments feel like rolling the dice.
Around 50 triggers, you start feeling the pain. At 400, it is simply unmanageable without a framework.
One Interface, One Factory
The solution starts with a contract. Every trigger handler in the org must implement the same interface. No exceptions, no shortcuts, no “I’ll just put this one thing directly in the trigger.”
Here is the interface:
public interface ITrigger {
void bulkBefore();
void bulkAfter();
void beforeInsert(SObject so);
void beforeUpdate(SObject oldSo, SObject so);
void beforeDelete(SObject so);
void afterInsert(SObject so);
void afterUpdate(SObject oldSo, SObject so);
void afterDelete(SObject so);
void afterUndelete(SObject so);
void andFinally();
}
Ten methods. That is the entire contract. Every trigger handler implements all of them, even if some are empty. The consistency is the point. When you open any handler in the org, you already know the shape of it before you read a single line.
The second piece is the factory. TriggerFactory is a single class that sits between the trigger file and the handler. It receives the trigger event, instantiates the correct handler, and orchestrates the execution:
public class TriggerFactory {
private static Boolean Disabled;
public static void createAndExecuteHandler(Type t) {
ITrigger handler = getHandler(t);
if (handler == null) {
throw new TriggerException('No Trigger Handler found named: ' + t.getName());
}
execute(handler);
}
private static void execute(ITrigger handler) {
if (TriggerFactory.disabled == true || hasBypassTriggerPermission()) {
return;
}
if (trigger.isBefore) {
handler.bulkBefore();
if (trigger.isDelete) {
for (SObject so : trigger.old) {
handler.beforeDelete(so);
}
} else if (trigger.isInsert) {
for (SObject so : trigger.new) {
handler.beforeInsert(so);
}
} else if (trigger.isUpdate) {
for (SObject so : trigger.old) {
handler.beforeUpdate(so, trigger.newMap.get(so.Id));
}
}
} else {
handler.bulkAfter();
if (trigger.isDelete) {
for (SObject so : trigger.old) {
handler.afterDelete(so);
}
} else if (trigger.isInsert) {
for (SObject so : trigger.new) {
handler.afterInsert(so);
}
} else if (trigger.isUpdate) {
for (SObject so : trigger.old) {
handler.afterUpdate(so, trigger.newMap.get(so.Id));
}
} else if (trigger.isUndelete) {
for (SObject so : trigger.new) {
handler.afterUndelete(so);
}
}
}
handler.andFinally();
}
}
With these two pieces in place, every trigger file in the org becomes a single line:
trigger WorkOrderTrigger on WorkOrder (before insert, before update, before delete,
after insert, after update, after delete,
after undelete) {
TriggerFactory.createAndExecuteHandler(WorkOrderHandler.class);
}
That is it. The entire trigger file. When a new developer joins the team, they do not need to understand the framework internals. They just need to know: “Create a handler class that implements ITrigger, and register it with TriggerFactory.” Within five minutes, they are productive.
The Handler Lifecycle
The interface looks simple, but the execution order is where the real design lives. The factory calls these methods in a specific sequence, and understanding that sequence is what separates a handler that survives at scale from one that breaks under load.
Here is how it flows:
Step 1: bulkBefore() runs exactly once, before any per-record methods fire. This is where you do all of your SOQL. You query everything you need, and you store it in instance-level maps and collections on the handler class. By the time the per-record methods start executing, every piece of data you could possibly need is already cached in memory.
Step 2: Per-record before methods (beforeInsert, beforeUpdate, beforeDelete) run once for each record in the trigger batch. The critical rule here is: no SOQL, no DML, no callouts. You are only allowed to read from the collections you populated in bulkBefore() and modify the records in front of you. This is where field-level logic lives. Setting defaults, validating values, stamping calculated fields.
Step 3: bulkAfter() runs once before the after-context per-record methods. Same idea as bulkBefore(), but for queries that depend on records already having IDs (because they have been committed to the database in the before phase).
Step 4: Per-record after methods (afterInsert, afterUpdate, afterDelete, afterUndelete) follow the same rules. No queries, no DML. You are building up collections of records that need to be created, updated, or deleted.
Step 5: andFinally() runs once after all records have been processed. This is where you perform all DML operations, enqueue callouts, fire platform events, and handle errors. Everything that touches the database happens here, in bulk.
Here is what this looks like in practice. Imagine a handler for an Order object that needs to look up related Accounts and stamp a region on each order before it is saved:
public without sharing class OrderHandler implements ITrigger {
// Instance-level collections, populated in bulk methods
private Map<Id, Account> m_accountsById = new Map<Id, Account>();
private List<Order> m_ordersToFlag = new List<Order>();
private List<Task> m_tasksToCreate = new List<Task>();
public void bulkBefore() {
// All SOQL happens here, once, for the entire batch
m_accountsById = OrderGateway.getAccountsByIds(trigger.new);
}
public void bulkAfter() {
// After-context queries if needed (records now have IDs)
}
public void beforeInsert(SObject so) {
Order ord = (Order) so;
// Use cached data, never query here
Account acct = m_accountsById.get(ord.AccountId);
if (acct != null && acct.Region__c != null) {
ord.Region__c = acct.Region__c;
}
}
public void beforeUpdate(SObject oldSo, SObject so) {
Order oldOrd = (Order) oldSo;
Order newOrd = (Order) so;
// Compare old vs new, apply field-level logic
if (oldOrd.Status != newOrd.Status && newOrd.Status == 'Activated') {
m_ordersToFlag.add(newOrd);
}
}
public void beforeDelete(SObject so) { }
public void afterInsert(SObject so) { }
public void afterUpdate(SObject oldSo, SObject so) {
Order newOrd = (Order) so;
// Build up collections for DML in andFinally
if (m_ordersToFlag.contains(newOrd)) {
m_tasksToCreate.add(new Task(
Subject = 'Follow up on activated order',
WhatId = newOrd.Id,
OwnerId = newOrd.OwnerId
));
}
}
public void afterDelete(SObject so) { }
public void afterUndelete(SObject so) { }
public void andFinally() {
// All DML happens here, once, in bulk
if (!m_tasksToCreate.isEmpty()) {
insert m_tasksToCreate;
}
}
}
The key insight is that the framework forces bulkification by design, not by discipline. A developer cannot accidentally put a query inside a per-record method because the framework makes it structurally obvious where queries belong and where they do not. It is the difference between a coding guideline that says “please don’t query in loops” and an architecture that makes it physically awkward to do so.
The Gateway Layer
There is a second layer of separation that becomes essential at scale: the gateway.
Handlers should not contain SOQL or DML directly. Instead, all data access for a given object lives in a dedicated gateway class. The handler calls gateway methods; the gateway returns clean collections.
public without sharing class OrderGateway {
// Get parent Accounts for a batch of Orders
public static Map<Id, Account> getAccountsByIds(List<SObject> records) {
List<Order> orders = (List<Order>) records;
Set<Id> accountIds = new Set<Id>();
for (Order ord : orders) {
if (ord.AccountId != null) {
accountIds.add(ord.AccountId);
}
}
if (accountIds.isEmpty()) {
return new Map<Id, Account>();
}
return new Map<Id, Account>([
SELECT Id, Name, Region__c, BillingState
FROM Account
WHERE Id IN :accountIds
]);
}
// Get related Contacts for a batch of Orders
public static Map<Id, Contact> getContactsByOrderIds(List<Order> orders) {
Set<Id> contactIds = new Set<Id>();
for (Order ord : orders) {
if (ord.CustomerAuthorizedById != null) {
contactIds.add(ord.CustomerAuthorizedById);
}
}
if (contactIds.isEmpty()) {
return new Map<Id, Contact>();
}
return new Map<Id, Contact>([
SELECT Id, FirstName, LastName, Email, Phone
FROM Contact
WHERE Id IN :contactIds
]);
}
}
This separation has three practical benefits.
First, it makes queries easy to find and optimize. When a governor limit exception points to a SOQL query, you know exactly which gateway file to open. You are not hunting through 1,000 lines of business logic trying to find the offending query buried inside an if block.
Second, it makes testing cleaner. You can test handler logic by mocking gateway responses. You can test gateway queries in isolation against known data sets. The two concerns do not bleed into each other.
Third, it creates a natural ownership model. The gateway file header points back to its trigger, and the handler file header points to its gateway. When someone asks “where does the Work Order data access live?”, the answer is always WorkOrderGateway.cls. No ambiguity, no searching.
Putting It All Together
Here is the full architecture in one picture:
OrderTrigger.trigger
└── TriggerFactory.createAndExecuteHandler(OrderHandler.class)
└── TriggerFactory.execute(handler)
├── handler.bulkBefore()
│ └── OrderGateway.getAccountsByIds()
│ └── OrderGateway.getContactsByOrderIds()
├── handler.beforeInsert(record) // per record, no SOQL
├── handler.beforeUpdate(old, new) // per record, no SOQL
├── handler.bulkAfter()
│ └── OrderGateway.getRelatedLineItems()
├── handler.afterInsert(record) // per record, no DML
├── handler.afterUpdate(old, new) // per record, no DML
└── handler.andFinally()
└── DML: insert tasks, update accounts, enqueue callouts
A single DML event on an Order flows through four layers: the trigger file (one line), the factory (routing), the handler (business logic), and the gateway (data access). Each layer has one job. Each layer is testable independently.
Kill Switches and Safety Valves
Production orgs need escape hatches. The framework has two built in.
Global disable. Calling TriggerFactory.setDisabled(true) turns off all trigger handlers in the current transaction. This is essential during data migrations. When you are loading 50,000 records through Data Loader, you do not want trigger logic firing on every single one. You disable the factory, load the data, and re-enable it.
// In anonymous Apex or a migration script
TriggerFactory.setDisabled(true);
// ... perform bulk data operations ...
TriggerFactory.setDisabled(false);
Per-user bypass. The BypassTriggerFactory custom permission lets you exempt specific users from trigger execution entirely. This is useful for integration users that push data from external systems and should not trigger downstream automation, or for admin users performing emergency data fixes.
public static Boolean hasBypassTriggerPermission() {
return FeatureManagement.checkPermission('BypassTriggerFactory');
}
If you have not worked with FeatureManagement.checkPermission() before, here is how it fits together. In Salesforce, a Custom Permission is a piece of metadata you create in Setup (or deploy via customPermissions/ in your SFDX project). It has no behavior on its own. It is just a named flag. The magic happens when you assign it to users through a Permission Set. Once a user has a Permission Set that includes BypassTriggerFactory, the call to FeatureManagement.checkPermission('BypassTriggerFactory') returns true for that user’s transactions.
The setup is three steps:
-
Create the Custom Permission. In Setup, search for “Custom Permissions” and create one with the API name
BypassTriggerFactory. Or deploy a metadata file atcustomPermissions/BypassTriggerFactory.customPermission-meta.xml. -
Create a Permission Set. Create a Permission Set (something like “Bypass Trigger Factory”) and add the custom permission to it under the “Custom Permissions” section.
-
Assign the Permission Set to whichever users or integration profiles need the bypass. Data migration users, API integration users, or an admin account you use for emergency fixes.
The beauty of this approach is that it is fully auditable. You can see exactly who has the bypass by checking Permission Set assignments. You can grant it temporarily and revoke it when the work is done. And because it runs through the standard Salesforce permission model, it respects profile hierarchies and can be managed through Permission Set Groups if your org uses them.
Compare this to the alternatives. Some orgs use Custom Settings or Custom Metadata to store bypass flags, which works but requires code changes or data manipulation to toggle. Others hardcode user IDs or profile names in the trigger, which is fragile and impossible to audit. The Custom Permission approach gives you a clean, declarative, admin-friendly kill switch per user.
These are not features you use every day. But on the day you need them, they save hours of firefighting.
What This Looks Like at Scale
I want to be specific about the numbers, because “at scale” means different things to different people.
The org I work in has roughly 426 triggers and 2,243 Apex classes. The trigger framework has been in place for years. Multiple teams across different time zones contribute to it daily. Here is what that looks like in practice:
Every trigger file is one line. Code reviews for trigger changes focus entirely on the handler logic. Reviewers never have to ask “is this query going to run inside a loop?” because the framework makes that structurally impossible.
Onboarding is fast. A new developer’s first task is usually to modify an existing handler. They open the file, see the same ten methods they have seen in every other handler, and know immediately where their logic belongs. There is no ramp-up period for understanding the trigger architecture.
Testing is predictable. Test classes follow the same pattern: create test data, perform DML, assert results. The handler lifecycle is invisible to the test. You do not need to worry about execution order or framework internals.
Modification history is traceable. Some of the handler files in this org have modification logs in their headers that span over 100 entries across eight years and dozens of developers. Every change is traceable to a ticket number. The framework did not make that happen, but it created the stability that made it possible. When the underlying architecture is chaotic, people stop documenting because everything feels temporary.
Common Mistakes to Avoid
Even with the framework in place, developers find creative ways to break the pattern. Here are the ones I see most often.
Querying inside per-record methods. This is the number one violation. Someone needs a piece of data they forgot to cache in bulkBefore(), so they add a quick query inside beforeUpdate. It works in their unit test with one record. It explodes in production when a batch job fires with 200.
Performing DML in before methods. The beforeUpdate method should only modify the record in front of it. If you need to create or update other records, add them to an instance-level collection and process them in andFinally().
Skipping the gateway. It is tempting to inline a small query directly in the handler. “It is just one line.” But that one line becomes ten, and then twenty, and soon the handler is 1,000 lines of interleaved business logic and data access. The gateway exists precisely to prevent this creep.
Using local variables instead of instance-level collections. If you declare a list inside bulkBefore() and try to use it in beforeInsert(), it will not exist. The handler’s instance state is what connects the bulk methods to the per-record methods. Prefix your collections with m_ so they are visually distinct and obviously scoped to the handler instance.
Getting Started
If you are reading this and your org does not have a trigger framework, the good news is that you do not need to refactor everything at once. The adoption path is incremental.
Start with three files:
- ITrigger.cls (the interface)
- TriggerFactory.cls (the dispatcher)
- One handler and one gateway for the object you are working on right now
Refactor that one trigger to use the framework. Deploy it. Let it run in production for a week. Once you are confident it works, refactor the next trigger. Then the next. Over the course of a few months, your org will naturally migrate to the new pattern as developers touch existing triggers for other reasons.
The framework is not clever. It does not use reflection tricks or metaprogramming. It does not require a package install or a managed dependency. It is just an interface, a factory, and a convention. That simplicity is what makes it survive contact with reality. Clever frameworks get abandoned when the person who wrote them leaves. Simple frameworks get adopted because anyone can understand them.
After working inside an org with 400+ triggers for years, I can tell you with confidence: the framework is not the interesting part. The interesting part is what it enables. When developers trust the architecture, they focus on solving business problems instead of fighting the platform. When code reviews are predictable, they actually catch real bugs instead of getting bogged down in structural debates. When testing is straightforward, people actually write tests instead of treating them as a deployment checkbox.
That is what scaling really means. Not just surviving the load, but creating the conditions where good work can happen consistently, across teams, across years.