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.
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:
- Original record loaded from database or initialized for insert.
- All old field values are populated.
- New record values loaded from request and overwrite old values.
- System validation rules run.
- `before` triggers execute.
- System validation rules (again, if `before` trigger changed values).
- Duplicate rules run.
- Record is saved to the database (but not yet committed).
- `after` triggers execute.
- Assignment rules, auto-response rules, workflow rules, processes (Process Builder/Flows), escalation rules execute.
- Roll-up summary fields are calculated.
- Parent records are locked.
- Criteria-based sharing evaluation.
- 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!
- 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.
- 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.
- 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.
- 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.
- On `Opportunity` `after insert` and `after update`, if an Opportunity is moved to 'Closed Won':
- 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:
- Salesforce Apex Developer Guide: Apex Triggers - The official documentation is always the best source for detailed information.
- Trailhead: Apex Triggers Module - Interactive learning module by Salesforce.
- Apex Best Practices for Triggers and Bulk Operations - Crucial for writing performant code.
- Search for "Salesforce Trigger Framework" on GitHub - Explore various open-source trigger frameworks implemented by the community for inspiration and advanced patterns.