Apex Trigger Best Practices: One Trigger Per Object
Apex trigger best practices that hold up in real orgs: one trigger per object, a handler class, bulkified queries, change checks, and a recursion guard.
TL;DR
Write one trigger per object and keep it to a single line that calls a handler class. Put the logic in that class, query and save in bulk, only act on records that changed, and guard against the trigger firing twice with a static set of Ids. Test every trigger with 200 records, not one.
Why Trigger Rules Exist At All
A trigger is the easiest Apex to write and the easiest to get wrong. It runs every time a record is saved, from any source. That means a user clicking Save, a data load of 50,000 rows, a Flow, an integration, and another trigger. Your code does not get to pick.
Most trigger bugs come from forgetting that. The code works when you test one record by hand. Then a data load arrives, and it fails with a limit error. Or two triggers on the same object run in an order nobody chose, and a field ends up with the wrong value.
Every rule below answers one of those problems. Learn the problem first and the rule stops feeling like style.
Rule 1: One Trigger Per Object
Salesforce does not promise the order that two triggers on the same object run
in. If you have AccountTrigger and AccountRatingTrigger, both on
before update, either one might go first. When both touch the same field, you
get bugs that come and go.
So write one trigger per object and list every event you need on it. When a new requirement shows up, you add a method to the handler. You do not add a second trigger.
Rule 2: Keep The Trigger Body To One Line
The trigger itself should do nothing but hand off. All the logic lives in a class. Here is the whole pattern in one block: the trigger, and the handler it calls.
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
AccountTriggerHandler.run();
}
public with sharing class AccountTriggerHandler {
public static void run() {
switch on Trigger.operationType {
when BEFORE_INSERT {
setDefaults((List<Account>) Trigger.new);
}
when BEFORE_UPDATE {
setDefaults((List<Account>) Trigger.new);
}
when AFTER_INSERT, AFTER_UPDATE {
// Work that needs a saved record Id goes here.
}
}
}
public static void setDefaults(List<Account> accounts) {
for (Account acc : accounts) {
if (acc.Rating == null) {
acc.Rating = 'Warm';
}
}
}
}Three good things come from this shape.
First, the order is written down. You can read run() and see exactly what
happens on each event. Second, switch on Trigger.operationType replaces a
tangle of if (Trigger.isBefore && Trigger.isInsert) checks. Third, the real
work sits in methods that take a plain list. You can call setDefaults from a
test, a batch job, or another class without firing a trigger at all.
Rule 3: Never Query Or Save Inside A Loop
This is the rule that breaks the most orgs. A trigger gets up to 200 records at a time. A query inside the loop runs 200 times, and the limit is 100 queries per transaction. The first big data load fails.
The fix has the same three steps every time:
- Loop once and collect the Ids you need into a
Set. - Run one query for all of them and put the results in a
Map. - Loop again and read from the map.
This handler copies each contact's account phone number onto the contact. It runs one query no matter how many contacts arrive.
public with sharing class ContactPhoneDefaulter {
public static void copyAccountPhone(List<Contact> contacts) {
Set<Id> accountIds = new Set<Id>();
for (Contact c : contacts) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
if (accountIds.isEmpty()) {
return;
}
Map<Id, Account> accountsById = new Map<Id, Account>(
[SELECT Id, Phone FROM Account WHERE Id IN :accountIds]
);
for (Contact c : contacts) {
Account parent = accountsById.get(c.AccountId);
if (parent != null && c.Phone == null) {
c.Phone = parent.Phone;
}
}
}
}The same goes for saving. Collect the records you need to change into a list,
then run one update after the loop. If you want to see the collections side of
this in more depth, the Apex collections lesson
covers sets and maps from the start.
Rule 4: Change Fields In Before, Touch Other Records In After
Pick the event by what you are changing.
- Before triggers are for changing the record being saved. You set the field
on the record in
Trigger.newand Salesforce saves it for you. No DML, and no second trigger run. - After triggers are for work that needs the record's Id, or that changes other records. On insert, the Id does not exist until after the save.
A common mistake is to update the same record in an after trigger. That costs a DML statement and fires the trigger again. If the field is on the record being saved, move the logic to before.
Rule 5: Only Act On Records That Changed
An update trigger fires on every save, even when the field you care about did not change. Someone edits the phone number, and your rating logic runs anyway. On a big data load, that is wasted work and wasted limits.
Compare the new value to the old one with Trigger.oldMap. Pass both into the
handler so the method is easy to test.
public with sharing class AccountRatingWatcher {
public static List<Account> ratingChanged(List<Account> updated, Map<Id, Account> oldMap) {
List<Account> changed = new List<Account>();
for (Account acc : updated) {
Account previous = oldMap.get(acc.Id);
if (previous != null && acc.Rating != previous.Rating) {
changed.add(acc);
}
}
return changed;
}
}Then do the expensive work only on the list that comes back. Remember that
Trigger.oldMap is null on insert, because there is no old version yet. The
account rating change detector
challenge is this exact method, graded against real maps.
Rule 6: Guard Against Recursion With A Set Of Ids
Recursion happens when your trigger saves a record, and that save fires the same trigger again. An after update trigger that updates related records, which then update the parent, can loop until it hits a limit.
The usual answer you hear is a static Boolean. It works for small tests, but it has a hole. When more than 200 records are saved at once, Salesforce runs the trigger once per chunk of 200, inside the same transaction. A Boolean that is set to true in the first chunk makes the trigger skip every chunk after it. Records 201 and up never get processed, and nothing tells you.
A static Set<Id> fixes that. It remembers which records were handled, not
whether the trigger ran.
public with sharing class OpportunityTriggerHandler {
private static Set<Id> processedIds = new Set<Id>();
public static List<Opportunity> firstTimeOnly(List<Opportunity> opps) {
List<Opportunity> fresh = new List<Opportunity>();
for (Opportunity opp : opps) {
if (opp.Id == null || processedIds.contains(opp.Id)) {
continue;
}
processedIds.add(opp.Id);
fresh.add(opp);
}
return fresh;
}
}A static variable lives for the whole transaction, which is the lifetime a guard needs. The trigger recursion guard challenge walks through this pattern, including why a null Id must never go into the set.
Rule 7: Use addError For Validation, Not Exceptions
When a trigger needs to stop a save, call addError on the record. The user sees
a clean message next to the record, and in a bulk save only the bad records fail.
Throwing an exception instead rolls back the whole batch with an ugly stack
trace.
public with sharing class OpportunityValidator {
public static void requireAmountWhenWon(List<Opportunity> opps) {
for (Opportunity opp : opps) {
if (opp.StageName == 'Closed Won' && opp.Amount == null) {
opp.addError('Closed Won opportunities need an amount.');
}
}
}
}Before you write validation in Apex, check whether a validation rule can do it. Declarative rules are easier for admins to read and change. Reach for a trigger when the check needs a query or a loop.
Rule 8: Give Yourself An Off Switch
Sooner or later you will need to load data without your trigger running. A one-time migration is the classic case. Plan for it on day one.
The simplest switch is a public static Boolean on the handler that run() checks
first. Tests and scripts can set it for the length of one transaction. For
something admins can flip without a deploy, check a custom permission with
FeatureManagement.checkPermission and assign it to the integration user.
Rule 9: Test With 200 Records
A trigger that passes a test with one record has proven almost nothing. Insert 200 records in your test, which is one full trigger chunk. That is where queries in loops and broken recursion guards show up.
@isTest
private class AccountBulkTest {
@isTest
static void handlesAFullChunk() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Bulk ' + i));
}
Test.startTest();
insert accounts;
Test.stopTest();
System.assertEquals(200, [SELECT COUNT() FROM Account WHERE Name LIKE 'Bulk %']);
}
}Add a case for update, not only insert, and assert on the field your trigger changes. If the test passes with 200 records, it will pass with 20,000. The starttest and stoptest guide explains why the save sits between those two calls.
The Short Version
If you only keep a few of these, keep these four. One trigger per object that
calls a handler. No query or DML inside a loop. Compare against Trigger.oldMap
before doing work. Test with 200 records.
These same rules come up in almost every Salesforce developer interview. The Salesforce developer interview questions post covers how to talk about them out loud.
Where To Practice Next
- Account rating change detector —
the
Trigger.oldMapcomparison from Rule 5. - Trigger recursion guard — the static set of Ids from Rule 6.
- Bulk update account industry — one query, one update, no loops around either.
- PD1 prep path — triggers and order of execution in exam order, with everything around them.
About Warren Walters
Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.
Visit Profile →