Table of Contents

  1. Rule 1: One Trigger Per Object
  2. Rule 2: Always Use a Handler Class
  3. Rule 3: Bulkify Everything
  4. Rule 4: Never Put SOQL Inside Loops
  5. Rule 5: Avoid DML Inside Loops
  6. Rule 6: Prevent Infinite Recursion
  7. Rule 7: Use the Right Trigger Context
  8. Rule 8: Always Check for Null
  9. Rule 9: Write Real Tests
  10. 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.

Wrong: Writing code that assumes 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 records

Rule 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;
}
Tip: Static variables in Apex persist for the duration of a transaction. Setting a static Boolean to true in the first trigger execution prevents subsequent executions from running the same logic.

Rule 7: Use the Right Trigger Context

Choosing the wrong trigger context is a common mistake that causes subtle bugs or missed requirements:

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:

@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.

Summary: These 10 rules aren't theoretical — they come directly from real-world production incidents. A trigger that violates any of these rules is a bug waiting to manifest. Master them early and you will spend far less time debugging and far more time building.