Skip to content
February 15, 2026·Updated ·23 min read

Apex Triggers: 10 Best Practices for Maintainable Code

Practical guide to building robust, performant, and maintainable Apex triggers in your Salesforce projects.

← Back

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:

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn