Apex Triggers in Action — Building a Scalable Trigger Framework Step‑by‑Step
Salesforce triggers are a powerful tool — they let you react to database events and enforce business logic in real time. But without discipline, triggers can quickly become tangled, hard to maintain, and difficult to test. In this post, we’ll walk through how to architect scalable trigger frameworks that hold up in real‑world enterprise environments.
Why Triggers Need a Framework
At their core, Apex triggers fire before or after DML events like insert, update, delete, and undelete. Simple logic works fine for small customizations, but in larger orgs with complex automation and multiple teams working together, triggers must be:
Bulk‑ready — capable of processing hundreds or thousands of records without hitting governor limits.
Maintainable — logic is organized cleanly, easy to read and update.
Testable — easy to write and run unit tests with predictable behavior.
Decoupled — business rules don’t live in giant trigger bodies.
A trigger framework solves these by abstracting logic into reusable patterns.
The Core Principles of a Good Trigger Framework
Before we dive into structure, here are the principles that guide our design:
1. One Trigger Per Object
Having multiple triggers on the same object leads to unpredictable execution order. With one trigger, we control order and avoid hard‑to‑trace bugs.
2. Use Handler Classes
Trigger logic belongs in handler classes, not the trigger body. This improves readability and separates concerns.
3. Bulkification Everywhere
Every handler method must work on collections. Avoid SOQL or DML inside loops — always use maps and lists.
4. Context‑Safe Logic
Different trigger contexts (before insert, after update, etc.) require different handling. A framework routes to correct methods without clutter.
Blueprint: The Scalable Trigger Framework
Here’s what our framework looks like:
📌 Trigger Shell
The actual trigger file is minimal:
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
new TriggerDispatcher(new AccountTriggerHandler()).run();
}
This delegates all logic to a dispatcher and a handler class — a clean separation of responsibilities.
📌 Trigger Dispatcher
The dispatcher identifies which trigger event occurred and calls the relevant handler method:
public with sharing class TriggerDispatcher {
private TriggerHandler handler;
public TriggerDispatcher(TriggerHandler handler) {
this.handler = handler;
}
public void run() {
if(Trigger.isBefore) {
if(Trigger.isInsert) handler.beforeInsert(Trigger.new);
if(Trigger.isUpdate) handler.beforeUpdate(Trigger.new, Trigger.oldMap);
}
if(Trigger.isAfter) {
if(Trigger.isInsert) handler.afterInsert(Trigger.new);
if(Trigger.isUpdate) handler.afterUpdate(Trigger.new, Trigger.oldMap);
}
}
}
This class helps keep our trigger logic consistent and extensible.
📌 Abstract Trigger Handler
Define a base class with stubs for each event:
public abstract class TriggerHandler {
public virtual void beforeInsert(List<SObject> newRecords) {}
public virtual void beforeUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
public virtual void afterInsert(List<SObject> newRecords) {}
public virtual void afterUpdate(List<SObject> newRecords, Map<Id, SObject> oldMap) {}
}
This lets each concrete handler implement only the events it cares about.
📌 Concrete Handler Example
Here’s a handler for Account:
public with sharing class AccountTriggerHandler extends TriggerHandler {
public override void beforeInsert(List<SObject> newRecords) {
List<Account> accounts = (List<Account>) newRecords;
for(Account acc : accounts) {
if(String.isBlank(acc.Industry)) {
acc.Industry = 'Unknown';
}
}
}
public override void afterInsert(List<SObject> newRecords) {
List<Account> accounts = (List<Account>) newRecords;
// Example: send welcome email using async operations
MyAsyncEmailService.sendWelcomeEmails(accounts);
}
}
This keeps logic focused, readable, and testable.
Recursion Control
Triggers can inadvertently reinvoke themselves when handler logic performs DML. A common pattern to prevent recursion is:
public class TriggerExecutionLock {
private static Set<String> executed = new Set<String>();
public static Boolean isFirstRun(String key) {
if(executed.contains(key)) return false;
executed.add(key);
return true;
}
}
Use like:
if(TriggerExecutionLock.isFirstRun('AccountBeforeInsert')) {
// …logic…
}
This prevents repeat execution within the same context.
Unit Tests That Matter
Testing is essential in a scalable framework. Write tests that:
Cover different contexts (
before insert,after update, etc.)Use bulk test data
Assert correct logic, not just code coverage
Example test:
@IsTest
static void testBeforeInsertDefaultsIndustry() {
Account a = new Account(Name='Test Co.');
insert a;
a = [SELECT Industry FROM Account WHERE Id=:a.Id];
System.assertEquals('Unknown', a.Industry);
}
Real‑World Considerations
1. Async Processing
Logic not required in the current transaction (e.g., sending emails, callouts) should use @future, Queueable, or Platform Events.
2. Exception Handling
Gracefully handle errors with custom exceptions and meaningful logs. Don’t let unhandled exceptions block unrelated automation.
3. Shared Utility Services
Common operations (logging, error formatting, batch helper methods) should live in reusable utility classes.
Summary — Why This Framework Works
| Goal | How the Framework Helps |
|---|---|
| Predictable behavior | Single trigger + dispatcher |
| Maintainability | Handler classes, clear separation |
| Bulk safety | Collection‑based methods |
| Scalability | Context routing, recursion control |
| Testability | Focused logic with robust test patterns |
What’s Next?
Once your triggers follow this structure, you’ll be able to:
onboard new team members faster,
support cross‑functional automation,
ship changes confidently,
and scale Salesforce implementations for the long term.