Table of Contents
- Rule 1: One Trigger Per Object
- Rule 2: Always Use a Handler Class
- Rule 3: Bulkify Everything
- Rule 4: Never Put SOQL Inside Loops
- Rule 5: Avoid DML Inside Loops
- Rule 6: Prevent Infinite Recursion
- Rule 7: Use the Right Trigger Context
- Rule 8: Always Check for Null
- Rule 9: Write Real Tests
- Rule 10: Keep Business Logic Out of Triggers
Apex triggers are one of the most powerful tools in Salesforce development — and one of the most commonly misused. A poorly written trigger can cause governor limit exceptions in production, create infinite loops, or fail silently when processing large data loads. These 10 rules, built from hard-won experience, will help you write triggers that are maintainable, performant, and safe.
Rule 1: One Trigger Per Object
Salesforce allows multiple triggers on the same object, but the order of execution is not guaranteed. When two triggers on the same object both handle before insert, you have no control over which runs first — and this unpredictability causes subtle, hard-to-reproduce bugs.
The rule is simple: one trigger per object, all contexts handled in that single trigger. Route each context to the appropriate handler method.
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
AccountTriggerHandler handler = new AccountTriggerHandler();
if (Trigger.isBefore) {
if (Trigger.isInsert) handler.beforeInsert(Trigger.new);
if (Trigger.isUpdate) handler.beforeUpdate(Trigger.new, Trigger.oldMap);
if (Trigger.isDelete) handler.beforeDelete(Trigger.old);
}
if (Trigger.isAfter) {
if (Trigger.isInsert) handler.afterInsert(Trigger.new);
if (Trigger.isUpdate) handler.afterUpdate(Trigger.new, Trigger.oldMap);
}
}Rule 2: Always Use a Handler Class
Triggers should contain zero business logic. They are routing mechanisms — nothing more. All logic goes in a separate handler class. This separation makes your code testable, readable, and reusable.
Why does this matter? You cannot call a trigger directly in a test. But you can instantiate a handler class and call its methods directly, giving you fine-grained control over what you test. Handler classes also make it easy to reuse logic from other places (like Batch Apex or Queueable jobs).
Rule 3: Bulkify Everything
The single most important concept in Apex development is bulkification. Triggers process records in batches of up to 200 at a time during bulk DML operations — but they can process far more in practice (Data Loader uploads, bulk API calls, Batch Apex). Your trigger must handle 1 record and 200 records identically.
Trigger.new.size() == 1 is the root cause of most production governor limit violations.The pattern to internalize: collect all IDs/values first, do one SOQL query to get everything you need, then process in a map lookup.
Rule 4: Never Put SOQL Inside Loops
This is the #1 governor limit violation in Apex. Each SOQL query inside a loop multiplies by the number of iterations. Process 200 records with a SOQL in the loop = 200 SOQL queries. The governor limit is 100 per transaction. You will hit it.
// WRONG — SOQL inside loop
for (Opportunity opp : Trigger.new) {
Account acc = [SELECT Id, Name FROM Account WHERE Id = :opp.AccountId]; // VIOLATION
// process...
}
// CORRECT — collect IDs, query once, use a map
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
accountIds.add(opp.AccountId);
}
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
for (Opportunity opp : Trigger.new) {
Account acc = accountMap.get(opp.AccountId);
// process safely
}Rule 5: Avoid DML Inside Loops
The same reasoning applies to DML (insert, update, delete). Each DML statement in a loop counts against the 150 DML statements per transaction limit. Collect records to update, then perform DML once outside the loop.
// WRONG
for (Account acc : accountsToUpdate) {
acc.Rating = 'Hot';
update acc; // 1 DML per record — hits limit at 150 records
}
// CORRECT
for (Account acc : accountsToUpdate) {
acc.Rating = 'Hot';
}
update accountsToUpdate; // 1 DML for all recordsRule 6: Prevent Infinite Recursion
When a trigger updates a record, Salesforce fires the trigger again for that record. If the update condition is still true, you get infinite recursion — a loop that terminates with a "Maximum trigger depth exceeded" error in production. Use a static Boolean flag to prevent re-entry.
public class TriggerContext {
public static Boolean isRunning = false;
}
trigger AccountTrigger on Account (after update) {
if (TriggerContext.isRunning) return; // Already running — skip
TriggerContext.isRunning = true;
// ... your logic here
TriggerContext.isRunning = false;
}Rule 7: Use the Right Trigger Context
Choosing the wrong trigger context is a common mistake that causes subtle bugs or missed requirements:
- before insert / before update: Use when you need to set or modify field values on the records being saved. Changes here don't require a separate DML — they're part of the save operation.
- after insert / after update: Use when you need to relate other records, send emails, or perform actions that require the record to already have an ID.
- before delete: Use to prevent deletion or capture data before it's gone.
- after delete / after undelete: Use for cascading logic after a record is deleted or restored.
Rule 8: Always Check for Null
SOQL can return null for lookup fields. Users can clear fields. Records can arrive with incomplete data during migrations. Null pointer exceptions (Attempt to de-reference a null object) are one of the most common trigger errors in production. Defensively check before using lookup values.
// Safe pattern — check before dereferencing
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
Account acc = accountMap.get(opp.AccountId);
if (acc.AnnualRevenue != null) {
// safe to use acc.AnnualRevenue
}
}Rule 9: Write Real Tests
Salesforce requires 75% code coverage, but coverage alone proves nothing. A test that inserts a record and asserts nothing has just wasted your time. Effective tests:
- Test the specific behavior the trigger is supposed to enforce
- Test with bulk data (insert 200 records in a single DML)
- Use
System.assertEquals()to verify expected values - Test negative cases (what happens when the condition is NOT met)
@isTest
static void testBulkInsert_setsPhoneDefault() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test ' + i)); // no Phone field
}
Test.startTest();
insert accounts;
Test.stopTest();
List<Account> result = [SELECT Phone FROM Account WHERE Id IN :accounts];
for (Account a : result) {
System.assertEquals('N/A', a.Phone, 'Phone should default to N/A');
}
}Rule 10: Keep Business Logic Out of Triggers
Triggers should read like a table of contents, not a novel. If your trigger contains complex conditional logic, string manipulation, or calculations, extract it to the handler class or a service class. This makes the logic independently testable and reusable from Batch Apex, Queueable jobs, or other entry points without having to trigger DML operations.
The best trigger frameworks (FFLIB, Nebula, custom handler patterns) enforce this separation structurally. Even without a framework, following this rule manually will significantly improve the maintainability of your codebase.