Apex Trigger Best Practices: The One-Trigger-Per-Object Pattern Every Developer Must Know
By Firus Hanov ยท ยท 10 min read
Master the One-Trigger-Per-Object pattern with handler and service classes. Learn bulkification, recursion guards, bypass mechanisms, and bulk testing.

Here's a nightmare scenario every Salesforce developer eventually faces:
You inherit a production org with four triggers on the Account object. Nobody knows which one runs first. Two of them query the same data. One has business logic duplicated from another. The org has 500,000 Account records and the client just asked you to add one more thing to the trigger.
You open the code. You feel the cold sweat begin.
This is what happens when Apex triggers are written without a framework. In this guide, I'm going to show you the trigger architecture that eliminates this chaos forever: the One-Trigger-Per-Object pattern with a Handler class. ๐ฅ
Why Trigger Architecture Matters
Before we get into code, let's understand the problem we're solving.
When developers write trigger logic directly inside the trigger file, three things almost always happen:
Problem 1: Multiple triggers per object. Different developers add separate triggers for different features. Salesforce doesn't guarantee execution order between triggers on the same object โ your logic becomes non-deterministic.
Problem 2: Logic lives in the wrong place. Business logic buried inside a trigger can't be called from anywhere else โ not from a batch job, not from an API endpoint, not from a LWC controller.
Problem 3: Testing becomes painful. Triggers are hard to unit test directly. When logic is inside the trigger, you need to insert/update actual records in your test, which is slow and fragile.
The solution is the Trigger Handler Pattern โ and once you use it, you'll never go back.
The One-Trigger-Per-Object Pattern
The rule is simple: one trigger file per object, with all logic delegated to a handler class.
Step 1: The Trigger File (as thin as possible)
trigger AccountTrigger on Account (
before insert,
before update,
before delete,
after insert,
after update,
after delete,
after undelete
) {
AccountTriggerHandler.run();
}
That's it. The trigger file does one thing: it calls the handler. No logic. No SOQL. No DML. Just delegation.
Step 2: The Base TriggerHandler Class
This reusable base class handles the Trigger context variables and routes execution to the correct method:
public virtual class TriggerHandler {
protected virtual void beforeInsert() {}
protected virtual void beforeUpdate() {}
protected virtual void beforeDelete() {}
protected virtual void afterInsert() {}
protected virtual void afterUpdate() {}
protected virtual void afterDelete() {}
protected virtual void afterUndelete() {}
public static void run() {
TriggerHandler handler = getInstance();
if (handler == null) return;
if (!isTriggerActive()) return;
switch on Trigger.operationType {
when BEFORE_INSERT { handler.beforeInsert(); }
when BEFORE_UPDATE { handler.beforeUpdate(); }
when BEFORE_DELETE { handler.beforeDelete(); }
when AFTER_INSERT { handler.afterInsert(); }
when AFTER_UPDATE { handler.afterUpdate(); }
when AFTER_DELETE { handler.afterDelete(); }
when AFTER_UNDELETE { handler.afterUndelete(); }
}
}
private static TriggerHandler getInstance() {
return null;
}
private static Boolean isTriggerActive() {
return true;
}
}
Step 3: The Object-Specific Handler
public class AccountTriggerHandler extends TriggerHandler {
private List<Account> newAccounts;
private List<Account> oldAccounts;
private Map<Id, Account> newAccountMap;
private Map<Id, Account> oldAccountMap;
public AccountTriggerHandler() {
this.newAccounts = (List<Account>) Trigger.new;
this.oldAccounts = (List<Account>) Trigger.old;
this.newAccountMap = (Map<Id, Account>) Trigger.newMap;
this.oldAccountMap = (Map<Id, Account>) Trigger.oldMap;
}
public static void run() {
new AccountTriggerHandler().executeRun();
}
private void executeRun() {
if (Trigger.isBefore) {
if (Trigger.isInsert) beforeInsert();
if (Trigger.isUpdate) beforeUpdate();
}
if (Trigger.isAfter) {
if (Trigger.isInsert) afterInsert();
if (Trigger.isUpdate) afterUpdate();
}
}
protected override void beforeInsert() {
AccountService.setDefaults(newAccounts);
AccountService.validateNewAccounts(newAccounts);
}
protected override void beforeUpdate() {
AccountService.validateUpdates(newAccounts, oldAccountMap);
}
protected override void afterInsert() {
AccountService.createWelcomeTasks(newAccounts);
AccountService.syncToExternalSystem(newAccountMap.keySet());
}
protected override void afterUpdate() {
AccountService.handleStatusChange(newAccounts, oldAccountMap);
}
}
Notice what the handler does: it receives the trigger context, then delegates to a Service class. The handler is a traffic controller โ the Service class is where business logic actually lives.
Step 4: The Service Class (where logic belongs)
public class AccountService {
public static void setDefaults(List<Account> accounts) {
for (Account acc : accounts) {
if (String.isBlank(acc.BillingCountry)) {
acc.BillingCountry = 'United States';
}
if (acc.Type == null) {
acc.Type = 'Prospect';
}
}
}
public static void validateNewAccounts(List<Account> accounts) {
for (Account acc : accounts) {
if (String.isBlank(acc.Name)) {
acc.addError('Account Name is required.');
}
if (acc.Type == 'Customer' && acc.AnnualRevenue == null) {
acc.AnnualRevenue.addError('Annual Revenue is required for Customer accounts.');
}
}
}
public static void createWelcomeTasks(List<Account> newAccounts) {
List<Task> tasks = new List<Task>();
for (Account acc : newAccounts) {
tasks.add(new Task(
WhatId = acc.Id,
Subject = 'Welcome call with ' + acc.Name,
OwnerId = acc.OwnerId,
ActivityDate = Date.today().addDays(3),
Priority = 'Normal',
Status = 'Not Started'
));
}
if (!tasks.isEmpty()) {
insert tasks;
}
}
public static void handleStatusChange(
List<Account> newAccounts,
Map<Id, Account> oldMap
) {
List<Account> changedAccounts = new List<Account>();
for (Account acc : newAccounts) {
Account old = oldMap.get(acc.Id);
if (acc.Type != old.Type) {
changedAccounts.add(acc);
}
}
if (!changedAccounts.isEmpty()) {
AccountNotificationService.notifyOwners(changedAccounts);
}
}
}
The power of this pattern: AccountService.createWelcomeTasks() can now be called from a trigger, a batch job, a Flow's Apex action, or an API endpoint. Logic is reusable. โ
Trigger Best Practice #1: Bulkify Everything
Every method in your service layer must assume it receives a List<SObject>, never a single record. Triggers fire in batches of up to 200 records.
// โ WRONG โ only handles one record
public static void processAccount(Account acc) {
// ...
}
// โ
CORRECT โ handles any volume
public static void processAccounts(List<Account> accounts) {
// ...
}
Trigger Best Practice #2: No SOQL or DML in Loops
We covered this in the Governor Limits post, but it bears repeating in the context of triggers because it's so commonly violated:
// โ WRONG
for (Account acc : newAccounts) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
for (Contact c : contacts) {
update c;
}
}
// โ
CORRECT
Set<Id> accountIds = new Set<Id>();
for (Account acc : newAccounts) {
accountIds.add(acc.Id);
}
List<Contact> contacts = [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds];
// Process and collect updates...
update contacts; // Single DML
Trigger Best Practice #3: Use Trigger.newMap and Trigger.oldMap for Change Detection
One of the most common trigger patterns is detecting whether a specific field changed:
protected override void afterUpdate() {
List<Account> statusChangedAccounts = new List<Account>();
for (Account acc : newAccounts) {
Account old = oldAccountMap.get(acc.Id);
if (acc.Status__c != old.Status__c) {
statusChangedAccounts.add(acc);
}
}
if (!statusChangedAccounts.isEmpty()) {
AccountService.handleStatusChange(statusChangedAccounts);
}
}
By filtering to only changed records before calling the service, you avoid unnecessary processing and DML.
Trigger Best Practice #4: Prevent Recursion
Triggers can fire recursively โ your trigger updates a record, which fires the trigger again, which updates the record... and you're in an infinite loop.
The cleanest solution is a recursion guard:
public class TriggerRecursionGuard {
private static Set<Id> processedIds = new Set<Id>();
public static Boolean hasProcessed(Id recordId) {
return processedIds.contains(recordId);
}
public static void markProcessed(Id recordId) {
processedIds.add(recordId);
}
public static void reset() {
processedIds.clear();
}
}
In your handler:
protected override void afterUpdate() {
List<Account> unprocessed = new List<Account>();
for (Account acc : newAccounts) {
if (!TriggerRecursionGuard.hasProcessed(acc.Id)) {
unprocessed.add(acc);
TriggerRecursionGuard.markProcessed(acc.Id);
}
}
if (!unprocessed.isEmpty()) {
AccountService.processAccounts(unprocessed);
}
}
This ensures each record is only processed once per transaction.
Trigger Best Practice #5: Make Triggers Bypassable
In enterprise orgs, you often need to bypass triggers during data migrations or admin operations. The cleanest way is a Custom Metadata Type:
public class TriggerBypassManager {
private static Map<String, Boolean> bypassMap;
public static Boolean isBypassed(String objectName) {
if (bypassMap == null) {
bypassMap = new Map<String, Boolean>();
for (Trigger_Bypass__mdt bypass : [
SELECT Object_Name__c, Is_Active__c
FROM Trigger_Bypass__mdt
]) {
bypassMap.put(bypass.Object_Name__c, bypass.Is_Active__c);
}
}
return bypassMap.containsKey(objectName) && bypassMap.get(objectName);
}
}
// In your trigger:
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
if (!TriggerBypassManager.isBypassed('Account')) {
AccountTriggerHandler.run();
}
}
Now an admin can flip a metadata flag to disable the trigger without a deployment. This is essential for data migrations.
Trigger Best Practice #6: Test Triggers in Bulk
Your tests must verify bulk behavior, not just single-record scenarios:
@IsTest
private class AccountTriggerTest {
@IsTest
static void testAfterInsert_createsTasks() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(
Name = 'Test Account ' + i,
Type = 'Prospect'
));
}
Test.startTest();
insert accounts;
Test.stopTest();
Integer taskCount = [SELECT COUNT() FROM Task WHERE Subject LIKE 'Welcome call%'];
System.assertEquals(200, taskCount, 'One task should be created per account');
}
@IsTest
static void testBeforeInsert_setsDefaultCountry() {
Account acc = new Account(Name = 'Test Account');
Test.startTest();
insert acc;
Test.stopTest();
Account result = [SELECT BillingCountry FROM Account WHERE Id = :acc.Id];
System.assertEquals('United States', result.BillingCountry, 'Default country should be set');
}
}
Testing with 200 records proves your code is truly bulkified. A test with 1 record proves nothing about scalability.
The Complete Architecture at a Glance
AccountTrigger.trigger
โโโ AccountTriggerHandler (routes by trigger event)
โโโ AccountService (business logic, bulkified methods)
โโโ AccountSelector (encapsulates SOQL queries)
โโโ AccountNotificationService (email/notification logic)
Each layer has a single responsibility:
- Trigger: Delegates to handler, optionally checks bypass
- Handler: Routes by event type (before/after insert/update), filters changed records
- Service: Contains business logic, calls selectors for queries
- Selector: Owns all SOQL queries for the object
This is the same pattern used in enterprise Salesforce implementations at Fortune 500 companies. It's battle-tested, scalable, and maintainable.
Summary
The One-Trigger-Per-Object pattern transforms chaos into clarity. Here's the full checklist for every trigger you write:
- โ One trigger per object
- โ All logic delegated to a handler class
- โ Handler delegates to a service class
- โ
All methods accept
List<SObject>(bulkified) - โ No SOQL or DML inside loops
- โ
Change detection using
Trigger.oldMap - โ Recursion guard for after-update scenarios
- โ Bypass mechanism via Custom Metadata
- โ Bulk test with 200 records
Follow these practices and you'll write triggers that other developers thank you for โ not triggers they dread touching.
Need a trigger code review? Book a session with ApexSensei and I'll audit your trigger architecture, identify governor limit risks, and recommend refactoring paths. Start practicing on ApexSensei โ
What's the messiest trigger you've inherited? Tell me in the comments โ I want to hear your war stories. ๐ฌ