intermediate
Fundamentals

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.

8 min read
Warren Walters
apex
triggers
best-practices
bulkification

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:

  1. Loop once and collect the Ids you need into a Set.
  2. Run one query for all of them and put the results in a Map.
  3. 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.new and 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

WW

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 →
Share:

Related Posts