SFX Support Header
logo

Apex Triggers - Mastering Salesforce Development

Welcome to the comprehensive guide on Apex Triggers! This module will take you from the fundamentals to advanced concepts, equipping you with the skills to write robust and efficient trigger logic in Salesforce.

Need hands-on training?

1. Introduction to Apex Triggers

Apex Triggers are powerful tools in Salesforce for automating business processes and enforcing custom logic before or after a record is saved, deleted, or undeleted. They are similar to database triggers but specifically for Apex code in the Salesforce environment.

What is an Apex Trigger?

An Apex Trigger is a piece of Apex code that executes before or after DML (Data Manipulation Language) events on Salesforce records. These events include `insert`, `update`, `delete`, and `undelete` operations.

Why use Apex Triggers?

  • Complex Business Logic: Implement custom validation rules, update related records, or interact with external systems.
  • Data Integrity: Enforce sophisticated data integrity rules that standard validation rules cannot handle.
  • Automation: Automate processes that require code, such as calculations, data aggregations, or complex record relationships.
  • Prevent Inefficient Operations: Prevent users from performing certain operations or ensure data consistency.

Trigger vs. Workflow Rules vs. Process Builder vs. Flows

It's crucial to understand when to use Triggers versus declarative tools like Workflow Rules, Process Builder, and Flows. Generally, you should always prefer declarative tools first, and only resort to Apex Triggers when declarative options cannot meet the business requirements due to complexity or limitations.

Key Differences:

  • **Declarative Tools (Flows, Process Builder, Workflow Rules):** No code required, easier to build and maintain, faster for simpler automation.
  • **Apex Triggers:** Code-based, more powerful and flexible, necessary for complex logic, callouts, or highly specific data manipulations.

Basic Syntax of an Apex Trigger

The basic syntax for an Apex Trigger is:

trigger TriggerName on ObjectApiName (trigger_events) {
    // Your Apex code logic here
}

Example:

trigger AccountTrigger on Account (before insert, after update) {
    if (Trigger.isInsert && Trigger.isBefore) {
        // Logic for before insert
        System.debug('Executing before insert on Account');
    }
    if (Trigger.isUpdate && Trigger.isAfter) {
        // Logic for after update
        System.debug('Executing after update on Account');
    }
}

2. Understanding Trigger Events

Apex Triggers can execute at different stages of a DML operation. These stages are known as trigger events. Understanding these events is fundamental to writing effective and efficient triggers.

Before Events (before insert, before update, before delete)

Before events fire *before* a record is saved to the database. This is the ideal time to:

  • Validate or modify field values on the record being processed.
  • Prevent a DML operation from completing by adding errors.
  • Populate fields that should be set before saving.

You cannot use `Trigger.oldMap` in `before insert` as there's no old version of the record.

Example Scenario: Ensure all new `Opportunity` records have a 'Close Date' set, and if not, add an error.

trigger OpportunityValidationTrigger on Opportunity (before insert) {
    if (Trigger.isBefore && Trigger.isInsert) {
        for (Opportunity opp : Trigger.new) {
            if (opp.CloseDate == null) {
                opp.addError('Close Date is required for all new opportunities.');
            }
        }
    }
}

After Events (after insert, after update, after delete, after undelete)

After events fire *after* a record has been saved to the database. At this point, the records have an ID assigned (for inserts) and are accessible from the database. This is the ideal time to:

  • Access the record's ID (for newly inserted records).
  • Perform operations on related records (e.g., updating child records when a parent is updated).
  • Send emails or make callouts to external systems.
  • Create or update other records based on the current record's state.

Example Scenario: Create a default `Contact` when a new `Account` is inserted.

trigger AccountContactCreationTrigger on Account (after insert) {
    if (Trigger.isAfter && Trigger.isInsert) {
        List<Contact> contactsToInsert = new List<Contact>();
        for (Account acc : Trigger.new) {
            Contact newContact = new Contact(
                FirstName = 'Primary',
                LastName = acc.Name + ' Contact',
                AccountId = acc.Id
            );
            contactsToInsert.add(newContact);
        }
        if (!contactsToInsert.isEmpty()) {
            insert contactsToInsert;
        }
    }
}

Order of Execution with Triggers

Salesforce has a defined order of execution when a record is saved. Triggers play a specific role in this order. Understanding this helps you predict behavior and debug issues:

  1. Original record loaded from database or initialized for insert.
  2. All old field values are populated.
  3. New record values loaded from request and overwrite old values.
  4. System validation rules run.
  5. `before` triggers execute.
  6. System validation rules (again, if `before` trigger changed values).
  7. Duplicate rules run.
  8. Record is saved to the database (but not yet committed).
  9. `after` triggers execute.
  10. Assignment rules, auto-response rules, workflow rules, processes (Process Builder/Flows), escalation rules execute.
  11. Roll-up summary fields are calculated.
  12. Parent records are locked.
  13. Criteria-based sharing evaluation.
  14. Commit to database.

This order emphasizes why `before` triggers are for modifying the current record, and `after` triggers are for operations on other records or post-save actions.

3. Trigger Context Variables

Trigger context variables are special Apex variables that provide access to the records that caused the trigger to fire, as well as information about the trigger's execution context. These are crucial for writing dynamic and efficient trigger logic.

  • `Trigger.new`: Returns a list of the new versions of the sObject records. This list is only available in `insert`, `update`, and `undelete` triggers, and the records can be modified in `before` triggers.
  • `Trigger.old`: Returns a list of the old versions of the sObject records. This list is only available in `update` and `delete` triggers. The records in this list are read-only.
  • `Trigger.newMap`: A map of IDs to the new versions of the sObject records. Available in `before update`, `after insert`, `after update`, and `after undelete` triggers. Ideal for quickly looking up records by ID.
  • `Trigger.oldMap`: A map of IDs to the old versions of the sObject records. Available in `before update`, `after update`, and `after delete` triggers. Ideal for comparing old and new values.
  • `Trigger.isExecuting`: Returns `true` if the current context is a trigger, `false` otherwise. Useful for preventing recursion.
  • `Trigger.isInsert`: Returns `true` if the trigger is firing due to an `insert` operation.
  • `Trigger.isUpdate`: Returns `true` if the trigger is firing due to an `update` operation.
  • `Trigger.isDelete`: Returns `true` if the trigger is firing due to a `delete` operation.
  • `Trigger.isUndelete`: Returns `true` if the trigger is firing due to an `undelete` operation.
  • `Trigger.isBefore`: Returns `true` if the trigger is firing in the `before` context.
  • `Trigger.isAfter`: Returns `true` if the trigger is firing in the `after` context.

Using Context Variables Effectively

You'll often combine these context variables to create specific logic paths within a single trigger. This is the foundation of a robust trigger handler pattern.

trigger MyAccountTrigger on Account (before insert, before update, after insert, after update) {
    if (Trigger.isBefore) {
        if (Trigger.isInsert) {
            // Logic for before insert: Modify new records directly
            for (Account acc : Trigger.new) {
                if (acc.BillingCity == null) {
                    acc.BillingCity = 'Unknown';
                }
            }
        } else if (Trigger.isUpdate) {
            // Logic for before update: Compare old and new values, modify new records
            for (Account newAcc : Trigger.new) {
                Account oldAcc = Trigger.oldMap.get(newAcc.Id);
                if (newAcc.Rating == 'Hot' && oldAcc.Rating != 'Hot') {
                    newAcc.Description = 'Hot account identified by trigger.';
                }
            }
        }
    } else if (Trigger.isAfter) {
        if (Trigger.isInsert) {
            // Logic for after insert: Use new record IDs to create related records
            List<Opportunity> newOpps = new List<Opportunity>();
            for (Account acc : Trigger.new) {
                if (acc.NumberOfEmployees > 1000) {
                    newOpps.add(new Opportunity(Name = 'Large Account Opp for ' + acc.Name, AccountId = acc.Id, CloseDate = Date.today().addMonths(3), StageName = 'Prospecting'));
                }
            }
            if (!newOpps.isEmpty()) {
                insert newOpps;
            }
        } else if (Trigger.isUpdate) {
            // Logic for after update: Perform actions based on changes, e.g., send emails
            List<Account> accountsToNotify = new List<Account>();
            for (Account newAcc : Trigger.new) {
                Account oldAcc = Trigger.oldMap.get(newAcc.Id);
                if (newAcc.OwnerId != oldAcc.OwnerId) {
                    accountsToNotify.add(newAcc);
                }
            }
            if (!accountsToNotify.isEmpty()) {
                // Call a future method to send emails for owner changes
                EmailService.sendOwnerChangeNotification(accountsToNotify);
            }
        }
    }
}

public class EmailService {
    @future
    public static void sendOwnerChangeNotification(List<Account> accounts) {
        // Logic to send email notifications for account owner changes
        System.debug('Sending email notifications for ' + accounts.size() + ' account owner changes.');
        // Example: Messaging.SingleEmailMessage, etc.
    }
}

4. Trigger Best Practices & Frameworks

Writing robust and maintainable Apex Triggers requires adhering to several best practices to avoid common pitfalls like governor limits, recursion, and unmanageable code.

One Trigger Per Object

A fundamental best practice is to have only one trigger per sObject. This prevents issues with the order of execution between multiple triggers on the same object (which Salesforce doesn't guarantee). Instead, route all logic through a single trigger and delegate to a handler class.

Context-Specific Logic (Trigger Handler Pattern)

Implement a "Trigger Handler" or "Trigger Framework" to separate the trigger's logic from the trigger definition itself. The trigger acts as a dispatcher, calling methods in a separate Apex class based on the trigger context variables.

/* Trigger Definition (e.g., AccountTrigger.trigger) */
trigger AccountTrigger on Account (before insert, before update, after insert, after update, before delete, after delete, after undelete) {
    // Instantiate and run the handler. Pass Trigger context directly.
    AccountTriggerHandler handler = new AccountTriggerHandler();

    if (Trigger.isBefore) {
        if (Trigger.isInsert) {
            handler.onBeforeInsert(Trigger.new);
        } else if (Trigger.isUpdate) {
            handler.onBeforeUpdate(Trigger.new, Trigger.oldMap);
        } else if (Trigger.isDelete) {
            handler.onBeforeDelete(Trigger.old, Trigger.oldMap);
        }
    } else if (Trigger.isAfter) {
        if (Trigger.isInsert) {
            handler.onAfterInsert(Trigger.new, Trigger.newMap);
        } else if (Trigger.isUpdate) {
            handler.onAfterUpdate(Trigger.new, Trigger.oldMap);
        } else if (Trigger.isDelete) {
            handler.onAfterDelete(Trigger.old, Trigger.oldMap);
        } else if (Trigger.isUndelete) {
            handler.onAfterUndelete(Trigger.new, Trigger.newMap);
        }
    }
}

/* Handler Class Example (e.g., AccountTriggerHandler.cls) */
public class AccountTriggerHandler {

    // Best practice: Use a static variable to prevent recursion within a single transaction
    private static Boolean bypassTrigger = false;

    public static void bypassTriggerExecution() {
        bypassTrigger = true;
    }

    public static void resumeTriggerExecution() {
        bypassTrigger = false;
    }

    // Guard clause to exit early if trigger is bypassed
    private Boolean isBypassed() {
        return bypassTrigger;
    }

    public void onBeforeInsert(List<Account> newAccounts) {
        if (isBypassed()) return;
        System.debug('Account Trigger: Before Insert');
        // Logic for before insert accounts: e.g., validate fields, set default values
        for (Account acc : newAccounts) {
            if (acc.Industry == null) {
                acc.Industry = 'Other';
            }
        }
    }

    public void onBeforeUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        if (isBypassed()) return;
        System.debug('Account Trigger: Before Update');
        // Logic for before update accounts: e.g., compare old and new values, prevent updates
        for (Account newAcc : newAccounts) {
            Account oldAcc = oldAccountMap.get(newAcc.Id);
            if (newAcc.Name != oldAcc.Name && !UserInfo.isAdminUser()) {
                newAcc.Name.addError('Account Name cannot be changed by non-admin users.');
            }
        }
    }

    public void onBeforeDelete(List<Account> oldAccounts, Map<Id, Account> oldAccountMap) {
        if (isBypassed()) return;
        System.debug('Account Trigger: Before Delete');
        // Logic for before delete accounts: e.g., prevent deletion based on criteria
        for (Account acc : oldAccounts) {
            if (acc.NumberOfEmployees > 100) {
                acc.addError('Cannot delete accounts with more than 100 employees.');
            }
        }
    }

    public void onAfterInsert(List<Account> newAccounts, Map<Id, Account> newAccountMap) {
        if (isBypassed()) return;
        System.debug('Account Trigger: After Insert');
        // Logic for after insert accounts: e.g., create related records, send notifications
        List<Task> tasksToCreate = new List<Task>();
        for (Account acc : newAccounts) {
            tasksToCreate.add(new Task(Subject = 'Follow up with ' + acc.Name, WhatId = acc.Id, Status = 'Not Started', Priority = 'Normal'));
        }
        if (!tasksToCreate.isEmpty()) {
            insert tasksToCreate;
        }
    }

    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        if (isBypassed()) return;
        System.debug('Account Trigger: After Update');
        // Logic for after update accounts: e.g., update child records, make callouts
        List<Contact> contactsToUpdate = new List<Contact>();
        for (Account newAcc : newAccounts) {
            Account oldAcc = oldAccountMap.get(newAcc.Id);
            if (newAcc.BillingAddress != oldAcc.BillingAddress) {
                // Get related contacts and update their mailing addresses
                for (Contact con : [SELECT Id, AccountId, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry FROM Contact WHERE AccountId = :newAcc.Id]) {
                    con.MailingStreet = newAcc.BillingStreet;
                    con.MailingCity = newAcc.BillingCity;
                    con.MailingState = newAcc.BillingState;
                    con.MailingPostalCode = newAcc.BillingPostalCode;
                    con.MailingCountry = newAcc.BillingCountry;
                    contactsToUpdate.add(con);
                }
            }
        }
        if (!contactsToUpdate.isEmpty()) {
            update contactsToUpdate; // This DML could potentially re-fire Contact triggers
        }
    }

    public void onAfterDelete(List<Account> oldAccounts, Map<Id, Account> oldAccountMap) {
        if (isBypassed()) return;
        System.debug('Account Trigger: After Delete');
        // Logic for after delete accounts: e.g., clean up related custom settings, log deletions
        for (Account acc : oldAccounts) {
            System.debug('Account ' + acc.Name + ' (ID: ' + acc.Id + ') was deleted.');
        }
    }

    public void onAfterUndelete(List<Account> newAccounts, Map<Id, Account> newAccountMap) {
        if (isBypassed()) return;
        System.debug('Account Trigger: After Undelete');
        // Logic for after undelete accounts: e.g., reactivate related records
        for (Account acc : newAccounts) {
            System.debug('Account ' + acc.Name + ' (ID: ' + acc.Id + ') was undeleted.');
        }
    }
}

Bulkification

Apex Triggers fire for each record in a DML operation, but they should be written to handle collections of records (bulk operations) efficiently. Avoid DML or SOQL queries inside `for` loops. Instead, process lists of records in one go.

Bad Practice: SOQL inside a loop (hits governor limits quickly)

// Inefficient - will hit SOQL limits if Trigger.new has many records
for (Account acc : Trigger.new) {
    // This query runs for EACH account, quickly consuming your 100 SOQL query limit
    Contact c = [SELECT Id FROM Contact WHERE AccountId = :acc.Id LIMIT 1];
    if (c != null) {
        // do something with contact
    }
}

Good Practice: Bulkified SOQL (process all related records with one query)

// Efficient - collects all IDs first, then queries all related records once
Set<Id> accountIds = new Set<Id>();
for (Account acc : Trigger.new) {
    accountIds.add(acc.Id);
}
// Single SOQL query for all relevant contacts
List<Contact> contacts = [SELECT Id, AccountId, Name FROM Contact WHERE AccountId IN :accountIds];

// Now process the contacts efficiently, e.g., map them by AccountId
Map<Id, List<Contact>> contactsByAccountId = new Map<Id, List<Contact>>();
for (Contact con : contacts) {
    if (!contactsByAccountId.containsKey(con.AccountId)) {
        contactsByAccountId.put(con.AccountId, new List<Contact>());
    }
    contactsByAccountId.get(con.AccountId).add(con);
}

// Now you can iterate through your Trigger.new and access related contacts efficiently
for (Account acc : Trigger.new) {
    List<Contact> relatedContacts = contactsByAccountId.get(acc.Id);
    if (relatedContacts != null) {
        System.debug('Account ' + acc.Name + ' has ' + relatedContacts.size() + ' contacts.');
        // Do further logic for each account's contacts
    }
}

Error Handling in Triggers

Use `sObject.addError()` in `before` triggers to prevent saving a record and provide user-friendly error messages that appear on the Salesforce UI. In `after` triggers, you cannot directly add errors to the triggering records, so you might need to use a `try-catch` block and log errors, or create a related error object.

// Example of addError in a before trigger
trigger CaseStatusValidation on Case (before update) {
    if (Trigger.isUpdate && Trigger.isBefore) {
        for (Case newCase : Trigger.new) {
            Case oldCase = Trigger.oldMap.get(newCase.Id);
            // Prevent changing status from 'Closed' to 'Open'
            if (oldCase.Status == 'Closed' && newCase.Status == 'Open') {
                newCase.addError('Cannot re-open a Closed case directly. Create a new case or use the "Clone Case" action.');
            }
        }
    }
}

Bypassing Triggers (for specific scenarios)

Sometimes you need to perform DML (e.g., during data migrations, system integrations, or specific batch jobs) without firing triggers to avoid unwanted side effects or recursion. The static variable approach in the handler class (as shown in the handler class example above) is the common way to achieve this.

// Example of calling the bypass method from a different Apex class/context
public class DataMigrationService {
    public static void importAccounts(List<Account> accountsToImport) {
        // Temporarily bypass the Account trigger
        AccountTriggerHandler.bypassTriggerExecution();

        try {
            insert accountsToImport;
        } catch (DmlException e) {
            System.debug('Error during account import: ' + e.getMessage());
            // Log errors, handle partial successes
        } finally {
            // Always resume trigger execution in a finally block
            AccountTriggerHandler.resumeTriggerExecution();
        }
    }
}

5. Handling Recursive Triggers

A recursive trigger occurs when a trigger performs a DML operation that, in turn, causes the same trigger (or another trigger on the same object) to fire again, leading to an infinite loop and eventually a governor limit exception (e.g., "Maximum trigger depth exceeded").

What is Trigger Recursion?

Common scenario: An `after update` trigger on `Account` updates a field on the `Account` record itself. This update then re-fires the `after update` trigger for the *same* `Account` record, leading to an infinite loop if not handled.

Detecting and Preventing Recursion

The most common and effective way to prevent trigger recursion is by using a **static boolean variable** in a helper or handler class. Static variables retain their value throughout a single transaction context.

/* Part of AccountTriggerHandler.cls (from previous example) */
public class AccountTriggerHandler {
    // This static variable acts as a flag for the current transaction
    private static Boolean hasRun = false; 

    // Public method to check and set the flag
    public static Boolean isFirstRun() {
        if (hasRun) {
            return false; // Trigger has already executed in this transaction
        } else {
            hasRun = true; // Mark as run for this transaction
            return true;
        }
    }

    // You can also add a method to reset if needed for specific test scenarios
    public static void resetRunFlag() {
        hasRun = false;
    }

    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        // Check if the trigger has already run in this transaction context
        if (!isFirstRun()) {
            System.debug('Account After Update trigger skipped due to recursion prevention.');
            return; // Exit if already run
        }

        System.debug('Account Trigger: After Update (First Run)');
        List<Contact> contactsToUpdate = new List<Contact>();
        List<Account> accountsToReupdate = new List<Account>(); // For demonstrating recursion

        for (Account newAcc : newAccounts) {
            Account oldAcc = oldAccountMap.get(newAcc.Id);

            // Example of a DML operation that could cause recursion if not handled
            if (newAcc.Rating == 'Hot' && oldAcc.Rating != 'Hot') {
                // If rating changed to Hot, update a field on the Account itself
                // This would re-fire the Account After Update trigger
                newAcc.Description = 'Hot Account identified: Follow up required.';
                accountsToReupdate.add(newAcc);
            }
        }

        if (!accountsToReupdate.isEmpty()) {
            // DML that could cause recursion.
            // Because we checked `isFirstRun()` at the beginning of the handler,
            // the subsequent re-invocation of the trigger won't execute this logic.
            update accountsToReupdate;
        }
        
        // At the end of the handler, you might reset the flag if you want
        // it to fire again in a *different* transaction later in the execution context
        // AccountTriggerHandler.resetRunFlag(); // Uncomment if you need this specific behavior
    }
}

Important Note: Static variables are reset at the start of each **transaction**. This means if multiple DML operations occur within a single top-level transaction (e.g., a batch Apex job or a chain of Queueable jobs), the static variable will maintain its state across those operations within that single transaction context. If a *new transaction* starts (e.g., a separate `System.runAs()` block, a new HTTP request), the static variable will be reset to its initial value.

You can also create a dedicated utility class to manage these flags for multiple objects, often referred to as a "Trigger Bypass Utility":

public class TriggerBypassUtil {
    // A set to store the names of triggers/handlers that have already executed
    private static Set<String> handledTriggers = new Set<String>();

    // Method to check if a specific trigger/handler should be bypassed
    public static Boolean bypass(String triggerName) {
        if (handledTriggers.contains(triggerName)) {
            return true; // Already handled in this transaction, so bypass
        } else {
            handledTriggers.add(triggerName); // Mark as handled for this transaction
            return false; // Not handled yet, proceed with execution
        }
    }

    // Method to explicitly reset a trigger's bypass flag
    public static void reset(String triggerName) {
        handledTriggers.remove(triggerName);
    }

    // Method to reset all bypass flags (useful for testing)
    public static void resetAll() {
        handledTriggers.clear();
    }
}

// Usage in your trigger handler (e.g., AccountTriggerHandler.cls):
public class AccountTriggerHandler {
    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
        // Use a unique name for this specific trigger context
        if (TriggerBypassUtil.bypass('AccountAfterUpdate')) {
            System.debug('Account After Update bypassed.');
            return;
        }

        // Your trigger logic here (e.g., update current Account record, which could re-fire)
        List<Account> accountsToUpdate = new List<Account>();
        for (Account acc : newAccounts) {
            if (acc.Description == null) {
                acc.Description = 'Automatically set description.';
                accountsToUpdate.add(acc);
            }
        }

        if (!accountsToUpdate.isEmpty()) {
            update accountsToUpdate; // This will re-fire the trigger, but it will be bypassed
        }
        
        // Optional: Reset the flag if you anticipate another DML that should trigger it later
        // TriggerBypassUtil.reset('AccountAfterUpdate');
    }
}

6. Testing Apex Triggers

To deploy Apex code to production, you must have unit tests with at least 75% code coverage. For triggers, this means writing comprehensive tests that simulate various DML scenarios and assert the expected behavior of your trigger logic. Good test classes cover positive scenarios, negative scenarios, and bulk data operations.

Test Data Setup

Always create dedicated test data within your test methods. Do not rely on existing data in your Salesforce org. Use `Test.startTest()` and `Test.stopTest()` to isolate DML operations and reset governor limits for the test context.

@isTest
private class AccountTriggerTest {

    @isTest
    static void testBeforeInsertLogic() {
        // 1. Setup Test Data
        List<Account> testAccounts = new List<Account>();
        testAccounts.add(new Account(Name = 'Test Account 1', Industry = null)); // Industry will be set by trigger
        testAccounts.add(new Account(Name = 'Test Account 2', Industry = 'Agriculture'));

        Test.startTest(); // Start of test context - resets governor limits

        // 2. Perform DML that invokes the trigger (AccountTrigger on Account)
        insert testAccounts;

        Test.stopTest(); // End of test context - governor limits checked here

        // 3. Assertions: Verify the trigger's behavior
        // Retrieve the accounts from the database to check changes made by before insert
        List<Account> insertedAccounts = [SELECT Id, Name, Industry FROM Account WHERE Id IN :testAccounts];

        // Assert for the first account where industry was null
        System.assertEquals(2, insertedAccounts.size(), 'Two accounts should have been inserted.');
        System.assertEquals('Other', insertedAccounts[0].Industry, 'Industry should be set to "Other" by the trigger.');
        System.assertEquals('Agriculture', insertedAccounts[1].Industry, 'Existing Industry should not be overwritten.');
    }

    @isTest
    static void testAfterInsertContactCreation() {
        // 1. Setup Test Data
        List<Account> testAccounts = new List<Account>();
        testAccounts.add(new Account(Name = 'After Insert Test Account 1', NumberOfEmployees = 1500));
        testAccounts.add(new Account(Name = 'After Insert Test Account 2', NumberOfEmployees = 500)); // Should not create opp
        
        Test.startTest();

        insert testAccounts; // This will fire the after insert logic

        Test.stopTest();

        // 3. Assertions
        // Get the Account that should have created an Opportunity
        Account largeAccount = [SELECT Id, Name FROM Account WHERE Name = 'After Insert Test Account 1' LIMIT 1];
        Account smallAccount = [SELECT Id, Name FROM Account WHERE Name = 'After Insert Test Account 2' LIMIT 1];

        // Verify that an Opportunity was created for the large account
        Integer oppsForLargeAccount = [SELECT COUNT() FROM Opportunity WHERE AccountId = :largeAccount.Id];
        System.assertEquals(1, oppsForLargeAccount, 'An opportunity should be created for large accounts.');

        // Verify no Opportunity for the small account
        Integer oppsForSmallAccount = [SELECT COUNT() FROM Opportunity WHERE AccountId = :smallAccount.Id];
        System.assertEquals(0, oppsForSmallAccount, 'No opportunity should be created for small accounts.');
    }

    @isTest
    static void testBulkifiedInsert() {
        // 1. Setup Test Data (e.g., 200 accounts to test bulkification)
        List<Account> bulkAccounts = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            bulkAccounts.add(new Account(Name = 'Bulk Test Account ' + i, NumberOfEmployees = 1200));
        }

        Test.startTest();
        insert bulkAccounts; // Test insertion of a large number of records
        Test.stopTest();

        // 3. Assertions
        // Verify that 200 opportunities were created (if your after insert logic creates one per account)
        Integer createdOpps = [SELECT COUNT() FROM Opportunity WHERE AccountId IN :bulkAccounts];
        System.assertEquals(200, createdOpps, 'Should have created 200 opportunities for bulk accounts.');

        // Verify that the trigger ran without hitting governor limits
        // (This is implicitly tested by Test.stopTest() not throwing an exception)
    }

    @isTest
    static void testBeforeDeletePrevention() {
        // 1. Setup test data: an account that should not be deletable
        Account accToDelete = new Account(Name = 'Account to be deleted', NumberOfEmployees = 200);
        insert accToDelete;

        Test.startTest();
        Database.DeleteResult result = Database.delete(accToDelete, false); // Use partial success
        Test.stopTest();

        // 3. Assertions
        System.assertFalse(result.isSuccess(), 'Deletion should have failed.');
        System.assert(result.getErrors()[0].getMessage().contains('Cannot delete accounts with more than 100 employees.'), 'Correct error message should be displayed.');

        // Verify the account still exists
        Account remainingAccount = [SELECT Id FROM Account WHERE Id = :accToDelete.Id ALL ROWS]; // Use ALL ROWS for deleted records
        System.assertNotEquals(null, remainingAccount, 'Account should not have been deleted.');
    }

    @isTest
    static void testRecursiveTriggerBypass() {
        // Ensure the bypass flag is reset for this test
        AccountTriggerHandler.resetRunFlag(); 

        Account testAcc = new Account(Name = 'Recursion Test Account', Rating = 'Cold');
        insert testAcc; // Initial insert, trigger runs normally

        Test.startTest();
        // Update the rating to 'Hot', which will cause the trigger to try and update itself
        testAcc.Rating = 'Hot';
        update testAcc;
        Test.stopTest();

        Account updatedAcc = [SELECT Id, Name, Rating, Description FROM Account WHERE Id = :testAcc.Id];

        // Assert that the description was set by the trigger's first run
        System.assertEquals('Hot Account identified: Follow up required.', updatedAcc.Description, 'Description should be updated by the first trigger run.');

        // Verify that the trigger did NOT infinitely loop,
        // which is implicitly tested by the test completing without 'Maximum trigger depth exceeded'
        // You could add debug logs in the trigger handler to confirm how many times it was entered
        // and check system debug logs in your developer console after running the test.
    }

    @isTest
    static void testSystemRunAs() {
        // Create a test user
        Profile p = [SELECT Id FROM Profile WHERE Name='Standard User']; 
        User testUser = new User(Alias = 'testuser', Email='testuser@example.com', 
            EmailEncodingKey='UTF-8', LastName='User', LanguageLocaleKey='en_US', 
            LocaleSidKey='en_US', ProfileId = p.Id, 
            TimeZoneSidKey='America/Los_Angeles', UserName='testuser@example.com');
        insert testUser;

        // Create an account for the test
        Account acc = new Account(Name = 'Test Account for User Change');
        insert acc;

        Test.startTest();
        System.runAs(testUser) {
            // Test if the trigger prevents non-admin users from changing Account Name
            Account accToUpdate = [SELECT Id, Name FROM Account WHERE Id = :acc.Id];
            accToUpdate.Name = 'New Account Name by Test User';
            
            Database.SaveResult sr = Database.update(accToUpdate, false); // Allow partial success

            System.assertFalse(sr.isSuccess(), 'Non-admin user should not be able to update Account Name.');
            System.assert(sr.getErrors()[0].getMessage().contains('Account Name cannot be changed by non-admin users.'), 'Correct error message should be displayed.');
        }
        Test.stopTest();

        // Verify the account name was not changed
        Account originalAcc = [SELECT Name FROM Account WHERE Id = :acc.Id];
        System.assertEquals('Test Account for User Change', originalAcc.Name, 'Account name should remain unchanged.');
    }
}

7. Advanced Trigger Concepts

Once you've mastered the basics, you can explore more advanced patterns and integrations with Apex Triggers to handle complex scenarios, asynchronous operations, and integrations with external systems.

Asynchronous Processing (@future, Queueable Apex, Batch Apex)

For operations that are CPU-intensive, involve callouts to external systems, or exceed governor limits for synchronous execution, consider moving them out of the trigger's immediate (synchronous) execution context into asynchronous Apex.

  • `@future` methods: Best for simple, single-fire asynchronous tasks like sending emails or making callouts.
    • Caveats: Cannot take sObjects as parameters directly; pass IDs and query inside the future method. Limited to 10 future calls per transaction.
  • Queueable Apex: Offers more flexibility than `@future` methods. You can chain jobs, and it supports sObjects as parameters.
    • Advantages: Can chain up to 50 jobs, supports complex types like sObjects, allows for more control over execution.
  • Batch Apex: Designed for processing large volumes of records that would otherwise exceed governor limits.
    • Use Case: When you need to process thousands or millions of records (e.g., data cleansing, mass updates).

Example: Using `@future` for a Callout

If your trigger needs to interact with an external API, it must be done asynchronously. Callouts are only allowed in `after` triggers (as the record must be committed before an external call) and typically require `@future` or Queueable Apex.

/* In your AccountTriggerHandler.cls (part of onAfterUpdate or onAfterInsert) */
public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldAccountMap) {
    if (isBypassed()) return;

    Set<Id> accountsForExternalSync = new Set<Id>();
    for (Account newAcc : newAccounts) {
        Account oldAcc = oldAccountMap.get(newAcc.Id);
        if (newAcc.Website != oldAcc.Website || newAcc.Phone != oldAcc.Phone) {
            accountsForExternalSync.add(newAcc.Id);
        }
    }

    if (!accountsForExternalSync.isEmpty()) {
        // Call an asynchronous method to sync with external system
        ExternalSyncService.syncAccountDetails(accountsForExternalSync);
    }
}

/* New Apex Class: ExternalSyncService.cls */
public class ExternalSyncService {
    @future(callout=true) // Mark as a future method that allows callouts
    public static void syncAccountDetails(Set<Id> accountIds) {
        List<Account> accounts = [SELECT Id, Name, Website, Phone FROM Account WHERE Id IN :accountIds];

        // Example: Build an HTTP request to an external CRM
        HttpRequest req = new HttpRequest();
        req.setMethod('POST');
        req.setEndpoint('http://api.externalcrm.com/sync');
        req.setHeader('Content-Type', 'application/json');

        List<Map<String, Object>> dataToSend = new List<Map<String, Object>>();
        for (Account acc : accounts) {
            Map<String, Object> accData = new Map<String, Object>();
            accData.put('sfId', acc.Id);
            accData.put('name', acc.Name);
            accData.put('website', acc.Website);
            accData.put('phone', acc.Phone);
            dataToSend.add(accData);
        }
        req.setBody(JSON.serialize(dataToSend));

        Http http = new Http();
        try {
            HttpResponse res = http.send(req);
            if (res.getStatusCode() == 200) {
                System.debug('Successfully synced accounts with external CRM.');
            } else {
                System.error('Error syncing accounts: ' + res.getStatusCode() + ' - ' + res.getBody());
            }
        } catch (System.CalloutException e) {
            System.error('Callout error: ' + e.getMessage());
        }
    }
}

Example: Using Queueable Apex

Queueable Apex is preferred over `@future` when you need to pass complex objects, chain jobs, or have more control over job management.

/* In your AccountTriggerHandler.cls (part of onAfterInsert) */
public void onAfterInsert(List<Account> newAccounts, Map<Id, Account> newAccountMap) {
    if (isBypassed()) return;

    List<Account> eligibleAccounts = new List<Account>();
    for (Account acc : newAccounts) {
        if (acc.NumberOfEmployees > 500) {
            eligibleAccounts.add(acc);
        }
    }

    if (!eligibleAccounts.isEmpty()) {
        // Enqueue a Queueable job, passing sObjects directly
        System.enqueueJob(new AccountRevenueCalculator(eligibleAccounts));
    }
}

/* New Apex Class: AccountRevenueCalculator.cls */
public class AccountRevenueCalculator implements Queueable {
    private List<Account> accounts;

    public AccountRevenueCalculator(List<Account> accounts) {
        this.accounts = accounts;
    }

    public void execute(QueueableContext context) {
        List<Account> accountsToUpdate = new List<Account>();
        for (Account acc : accounts) {
            // Simulate complex calculation
            Decimal totalRevenue = 0;
            for (Opportunity opp : [SELECT Id, Amount FROM Opportunity WHERE AccountId = :acc.Id AND StageName = 'Closed Won']) {
                if (opp.Amount != null) {
                    totalRevenue += opp.Amount;
                }
            }
            acc.AnnualRevenue = totalRevenue; // Assuming AnnualRevenue is a field to update
            accountsToUpdate.add(acc);
        }

        if (!accountsToUpdate.isEmpty()) {
            update accountsToUpdate;
        }
    }
}

Trigger Utilities and Helper Classes

Beyond the main trigger handler, you should create smaller, reusable utility classes for common tasks. This promotes code reuse, makes your code more modular, and easier to test.

  • Email Service: A class dedicated to sending various types of emails (e.g., notifications, alerts).
  • Logging Service: A utility for standardized logging of errors, warnings, or debug messages.
  • Validation Utilities: Reusable methods for common validation patterns.
  • Data Transformation Utilities: Methods to transform or cleanse data before DML.
public class CustomEmailService {
    public static void sendSingleEmail(String recipientEmail, String subject, String body) {
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        mail.setToAddresses(new List<String>{recipientEmail});
        mail.setSubject(subject);
        mail.setPlainTextBody(body);
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
    }

    // Overload for sending email to an ID
    public static void sendSingleEmail(Id targetObjectId, String subject, String body) {
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        mail.setTargetObjectId(targetObjectId); // Can be User Id, Contact Id, Lead Id, etc.
        mail.setWhatId(targetObjectId); // Optional: relate to a record
        mail.setTemplateId(null); // Or use an Email Template ID
        mail.setSubject(subject);
        mail.setPlainTextBody(body);
        mail.setSaveAsActivity(false); // Don't save as activity
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
    }
}

// Usage in trigger handler:
// CustomEmailService.sendSingleEmail('admin@example.com', 'High Value Account Created', 'New Account ' + acc.Name + ' created with annual revenue of ' + acc.AnnualRevenue);

8. Conclusion & Next Steps

Congratulations! You've now covered the essential concepts of Apex Triggers, from understanding their events and context variables to implementing best practices and handling advanced scenarios. Apex Triggers are a cornerstone of complex Salesforce automation, but always remember to prefer declarative solutions (Flows, Process Builder, Workflow Rules) where possible, resorting to Apex only when declarative options cannot meet the business requirements.

Key Takeaways

  • Always prefer declarative tools first for automation.
  • Implement the "One Trigger Per Object" pattern, delegating logic to a Trigger Handler Class.
  • Always bulkify your code to handle multiple records in a single transaction.
  • Prevent recursion in your triggers using static variables.
  • Write comprehensive unit tests with at least 75% code coverage, covering positive, negative, and bulk scenarios.
  • Use asynchronous Apex (`@future`, Queueable, Batch) for long-running processes, callouts, or to circumvent governor limits.
  • Develop reusable utility classes for common tasks to keep your code modular and maintainable.

Practice Exercises

To solidify your understanding, try implementing the following exercises in a Salesforce Developer Org. Remember to write test classes for each trigger!

  1. Contact Name Normalization:
    • Write a `before insert` and `before update` trigger on `Contact`.
    • Ensure that `FirstName` and `LastName` are always stored in Proper Case (e.g., "john doe" becomes "John Doe").
    • Add an error if `LastName` is blank.
  2. Account to Contact Address Sync:
    • Create an `after update` trigger on `Account`.
    • When an Account's `Billing Address` changes, update the `Mailing Address` of all directly related `Contact` records to match the Account's new Billing Address.
    • Ensure this is bulkified.
  3. Case Deletion Prevention:
    • Implement a `before delete` trigger on `Case`.
    • Prevent a `Case` from being deleted if its `Status` is 'Closed' or 'Escalated'. Provide a clear error message.
  4. Opportunity Stage Rollup (Advanced):
    • On `Opportunity` `after insert` and `after update`, if an Opportunity is moved to 'Closed Won':
      • Update a custom field on the related `Account` (e.g., `Last_Won_Opportunity_Date__c`) to `TODAY()`.
      • Consider if this should be synchronous or asynchronous based on potential complexity.
  5. External Data Sync (Advanced - Requires Mock/Real Callout):
    • Create an `after insert` or `after update` trigger on a custom object (e.g., `External_Lead__c`).
    • When a new record is created or a specific field (e.g., `Status__c`) is updated to 'Approved', make an asynchronous callout to a mock external API (or a real one if you have access) to simulate sending data.
    • Handle potential callout errors.

Recommended Resources

Continue your learning journey with these valuable resources:

Keep practicing, build, and debug! Mastery comes with consistent effort. Happy coding!