Apex triggers are at the heart of many Salesforce automations. Poorly designed, they quickly become a source of bugs, performance issues, and maintenance nightmares. Here are 10 essential best practices for developing professional triggers.
1. One Trigger Per Object, Delegation to Handler Class
❌ Bad Practice:
trigger AccountTrigger on Account (before insert, after insert, before update) {
if (Trigger.isBefore && Trigger.isInsert) {
// 200 lines of business logic...
}
if (Trigger.isAfter && Trigger.isInsert) {
// 150 lines of business logic...
}
}✅ Good Practice:
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
AccountTriggerHandler.handle();
}Delegate all logic to a handler class. This improves testability and readability.
2. Handler Pattern with Context-Specific Methods
Structure your handler to clearly manage each context:
public class AccountTriggerHandler {
public static void handle() {
if (Trigger.isBefore) {
if (Trigger.isInsert) beforeInsert(Trigger.new);
if (Trigger.isUpdate) beforeUpdate(Trigger.new, Trigger.oldMap);
}
if (Trigger.isAfter) {
if (Trigger.isInsert) afterInsert(Trigger.new);
if (Trigger.isUpdate) afterUpdate(Trigger.new, Trigger.oldMap);
}
}
private static void beforeInsert(List<Account> newAccounts) {
// Before insert logic
AccountService.setDefaultValues(newAccounts);
AccountService.validateBusinessRules(newAccounts);
}
private static void afterInsert(List<Account> newAccounts) {
// After insert logic
ContactService.createPrimaryContacts(newAccounts);
}
// ... other methods
}3. Logic Separation: Service Layer Pattern
Don't put business logic in the handler. Create reusable service classes:
public class AccountService {
public static void setDefaultValues(List<Account> accounts) {
for (Account acc : accounts) {
if (String.isBlank(acc.Industry)) {
acc.Industry = 'Technology';
}
if (acc.AnnualRevenue == null) {
acc.AnnualRevenue = 0;
}
}
}
public static void validateBusinessRules(List<Account> accounts) {
for (Account acc : accounts) {
if (acc.Type == 'Customer' && String.isBlank(acc.Phone)) {
acc.addError('Phone is required for Customer accounts');
}
}
}
}Benefits:
- Reusable from other contexts (batch, webservice)
- Testable in isolation
- Documented and centralized business logic
4. Bulkification: Always Think in Bulk
❌ Non-bulkified code:
private static void afterInsert(List<Account> newAccounts) {
for (Account acc : newAccounts) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
// SOQL in a loop = DANGER!
}
}✅ Bulkified code:
private static void afterInsert(List<Account> newAccounts) {
Set<Id> accountIds = new Set<Id>();
for (Account acc : newAccounts) {
accountIds.add(acc.Id);
}
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact con : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(con.AccountId)) {
contactsByAccount.put(con.AccountId, new List<Contact>());
}
contactsByAccount.get(con.AccountId).add(con);
}
// Bulk processing
}5. Prevent Infinite Recursion
A simple static boolean ("the trigger already ran") is a classic trap: when Data Loader or an API inserts more than 200 records, the platform splits the transaction into chunks of 200 and calls the trigger again for each chunk. With a boolean, the following chunks are silently skipped. Track the Ids already processed instead:
public class TriggerControl {
// Key = trigger name + context + Id: each record is processed once per context
// and per transaction, however many 200-record chunks there are.
private static Set<String> processed = new Set<String>();
public static Boolean markProcessed(String triggerName, Id recordId) {
// The context (BEFORE_INSERT, AFTER_UPDATE…) is part of the key,
// so the same record can still run in before and then in after.
return processed.add(triggerName + ':' + Trigger.operationType + ':' + recordId); // false if already present
}
}
// In the handler (after update):
public static void handleAfterUpdate(List<Account> newAccounts) {
List<Account> toProcess = new List<Account>();
for (Account acc : newAccounts) {
if (TriggerControl.markProcessed('AccountTrigger', acc.Id)) {
toProcess.add(acc); // first pass for this record
}
}
if (toProcess.isEmpty()) {
return; // all already processed: recursion avoided
}
// ... trigger logic on toProcess only
}6. Handle Modified Fields (before update)
Only process records where relevant fields have changed:
private static void beforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
List<Account> accountsToProcess = new List<Account>();
for (Account newAcc : newAccounts) {
Account oldAcc = oldMap.get(newAcc.Id);
// Only process if Industry field changed
if (newAcc.Industry != oldAcc.Industry) {
accountsToProcess.add(newAcc);
}
}
if (!accountsToProcess.isEmpty()) {
AccountService.recalculateIndustryMetrics(accountsToProcess);
}
}7. Unit Tests with > 85% Coverage
@isTest
private class AccountTriggerHandler_Test {
@isTest
static void testBeforeInsert_DefaultValues() {
// Arrange
List<Account> accounts = new List<Account>{
new Account(Name = 'Test Account 1'),
new Account(Name = 'Test Account 2', Industry = 'Healthcare')
};
// Act
Test.startTest();
insert accounts;
Test.stopTest();
// Assert
List<Account> insertedAccounts = [
SELECT Id, Industry, AnnualRevenue
FROM Account
WHERE Id IN :accounts
];
System.assertEquals('Technology', insertedAccounts[0].Industry,
'Default industry should be Technology');
System.assertEquals('Healthcare', insertedAccounts[1].Industry,
'Industry should remain Healthcare');
System.assertEquals(0, insertedAccounts[0].AnnualRevenue,
'Default revenue should be 0');
}
@isTest
static void testBulkInsert_200Records() {
// Test with 200 records to verify bulkification
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Bulk Test ' + i));
}
Test.startTest();
insert accounts;
Test.stopTest();
System.assertEquals(200, [SELECT COUNT() FROM Account],
'All 200 accounts should be inserted');
}
}8. Explicit Error Handling
public static void validateBusinessRules(List<Account> accounts) {
for (Account acc : accounts) {
// No try/catch: addError doesn't throw, it flags the record as failed.
// AnnualRevenue can be empty: that case is handled explicitly.
if (acc.Type == 'Customer' && (acc.AnnualRevenue == null || acc.AnnualRevenue < 10000)) {
acc.addError('Customer accounts must have revenue >= 10,000');
}
if (acc.BillingCountry == 'France' && String.isBlank(acc.SIRET__c)) {
acc.SIRET__c.addError('SIRET is mandatory for French companies');
}
}
}9. Documentation and Comments
/**
* @description Handler for Account trigger operations
* @author Aïcha Imène DAHOUMANE
* @date 2026-02-15
* @group Trigger Handlers
*/
public class AccountTriggerHandler {
/**
* @description Main entry point for Account trigger
* Delegates to specific methods based on trigger context
*/
public static void handle() {
// Implementation
}
/**
* @description Sets default values for new Account records
* - Default Industry to 'Technology' if blank
* - Default AnnualRevenue to 0 if null
* @param newAccounts List of new Account records
*/
private static void beforeInsert(List<Account> newAccounts) {
// Implementation
}
}10. Logging and Observability
public class TriggerLogger {
public static void logExecution(String triggerName, String context, Integer recordCount) {
System.debug(LoggingLevel.INFO,
String.format('Trigger: {0} | Context: {1} | Records: {2}',
new List<String>{triggerName, context, String.valueOf(recordCount)}));
}
public static void logError(String triggerName, Exception e) {
System.debug(LoggingLevel.ERROR,
String.format('Trigger: {0} | Error: {1} | Stack: {2}',
new List<String>{triggerName, e.getMessage(), e.getStackTraceString()}));
}
}
// Usage in the handler:
public static void handle() {
TriggerLogger.logExecution('AccountTrigger',
Trigger.operationType.name(), Trigger.new.size());
try {
// ... trigger logic
} catch (Exception e) {
TriggerLogger.logError('AccountTrigger', e);
throw e;
}
}Checklist Before Deploying a Trigger
- One trigger per object
- Logic delegated to handler class
- Service layer for reusable business logic
- 100% bulkified code (no SOQL/DML in loops)
- Recursion protection
- Explicit error handling
- Unit tests > 85% coverage
- Bulk tests (200+ records)
- Public methods documented
- Critical operations logged
Conclusion
Well-designed Apex triggers are:
- Performant: bulkification and query optimization
- Maintainable: structured, commented, and tested code
- Reliable: error handling and recursion protection
- Scalable: modular architecture with service layer
By applying these 10 best practices, you'll avoid 90% of common Apex trigger issues and build robust automations that withstand time and evolution.
Useful Resources: