Complete Apex Trigger Notes
This document provides a comprehensive overview of Apex Triggers in Salesforce, covering their purpose, types, best practices, and advanced topics.
1. What is an Apex Trigger?
An Apex Trigger is code that automatically runs before or after specific database events (DML) like:
INSERTUPDATEDELETEMERGEUNDELETE
Use Cases:
Automate business processes.
Validate or modify data before saving.
Create related records.
Call external systems after saving.
Maintain data consistency.
2. Types of Triggers
Before Triggers
Run before the data is saved to the database.
Best for:
Validations.
Changing values on the record before commit.
After Triggers
Run after records are committed.
Best for:
Using record IDs.
Creating related records.
Callouts (via asynchronous methods).
3. Trigger Events
| Event | Description |
| before insert | Before new records inserted. |
| before update | Before existing records updated. |
| before delete | Before records deleted. |
| after insert | After new records inserted. |
| after update | After existing records updated. |
| after delete | After records deleted. |
| after undelete | After records restored from Recycle Bin. |
4. Trigger Syntax
Basic structure:
trigger TriggerName on ObjectName (events) {
// logic
}
Example:
trigger AccountTrigger on Account (before insert) {
for (Account acc : Trigger.new) {
acc.Description = 'Updated by Trigger';
}
}
5. Trigger Context Variables
Salesforce provides helpful context variables:
| Context Variable | Description |
| Trigger.new | New versions of records. |
| Trigger.old | Old versions of records. |
| Trigger.newMap | Map of IDs → new records. |
| Trigger.oldMap | Map of IDs → old records. |
| Trigger.isInsert | True if fired on insert. |
| Trigger.isUpdate | True if fired on update. |
| Trigger.isDelete | True if fired on delete. |
| Trigger.isBefore | True if a before trigger. |
| Trigger.isAfter | True if an after trigger. |
| Trigger.isUndelete | True if fired on undelete. |
6. Trigger Best Practices
✅ One Trigger Per Object:
- Avoid multiple triggers per object for maintainability.
✅ Logic-less Trigger:
- Move logic into a separate handler class.
✅ Bulk-safe:
Always loop over
Trigger.new.Never assume only one record.
✅ Avoid DML or SOQL inside loops:
- Collect records and DML them once outside the loop.
✅ Avoid recursion:
- Use static flags to prevent infinite loops.
✅ Use context variables:
Example:
if (Trigger.isBefore && Trigger.isInsert) { // logic here }
7. Trigger Handler Pattern
Separate your logic into handler classes for better organization.
Trigger:
trigger AccountTrigger on Account (before insert) {
AccountTriggerHandler.handleBeforeInsert(Trigger.new);
}
Handler class:
public class AccountTriggerHandler {
public static void handleBeforeInsert(List<Account> newList) {
for (Account acc : newList) {
acc.Description = 'Handled by Trigger Handler';
}
}
}
8. Bulkification Example
Non-bulkified:
trigger BadTrigger on Contact (before insert) {
for (Contact con : Trigger.new) {
Account acc = [SELECT Name FROM Account WHERE Id = :con.AccountId]; // SOQL inside loop!
con.Description__c = acc.Name;
}
}
Bulkified:
trigger GoodTrigger on Contact (before insert) {
Set<Id> accountIds = new Set<Id>();
for (Contact con : Trigger.new) {
if (con.AccountId != null) {
accountIds.add(con.AccountId);
}
}
// SOQL outside loop
Map<Id, Account> accMap = new Map<Id, Account>(
[SELECT Name FROM Account WHERE Id IN :accountIds]
);
for (Contact con : Trigger.new) {
if (accMap.containsKey(con.AccountId)) {
con.Description__c = accMap.get(con.AccountId).Name;
}
}
}
9. Trigger Order of Execution
Salesforce executes:
Load old record (for updates)
Run validation rules
Execute before triggers
Duplicate rules
Save record (but not committed)
Execute after triggers
Assignment rules
Auto-response rules
Workflow rules
Process Builder
Escalation rules
Commit record
Post-commit logic (e.g. async jobs)
10. Test Class for Triggers
Example:
@isTest
private class AccountTriggerTest {
@isTest static void testInsert() {
// Prepare test data
Account acc = new Account(Name='Test Account');
insert acc; // This DML operation fires the trigger
// Verify the trigger's effect
Account result = [SELECT Description FROM Account WHERE Id = :acc.Id];
System.assertEquals('Handled by Trigger Handler', result.Description);
}
}
11. Governor Limits
Key limits:
150 DML statements/transaction
100 SOQL queries/transaction
50,000 records retrieved via SOQL
Max CPU time: 10,000 ms
Write bulk-safe code to avoid limits!
12. Real-Life Trigger Scenarios
✅ Set default field values
✅ Prevent field changes under certain conditions
✅ Create related child records
✅ Roll up custom summaries
✅ Notify users of changes
✅ Sync related objects
✅ Call external systems post-commit
13. Advanced Topics
✅ Future Methods & Queueable from Triggers
Why?
Triggers can’t do callouts directly.
Offload heavy logic.
Prevent hitting governor limits.
Future Methods
Annotated with
@future.No return values.
Primitive parameters only.
Example:
@future(callout=true)
public static void sendCallout(String accountId) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://example.com/api');
req.setMethod('POST');
req.setBody('Account Id=' + accountId);
Http http = new Http();
HttpResponse res = http.send(req);
}
Trigger:
trigger AccountTrigger on Account (after insert) {
for (Account acc : Trigger.new) {
MyFutureClass.sendCallout(acc.Id);
}
}
Queueable Apex
More powerful:
Allows complex logic.
Supports non-primitive parameters.
Chaining jobs.
Example:
public class AccountQueueable implements Queueable {
private List<Account> accounts;
public AccountQueueable(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
for (Account acc : accounts) {
acc.Description = 'Updated by Queueable';
}
update accounts;
}
}
Trigger:
trigger AccountTrigger on Account (after insert) {
System.enqueueJob(new AccountQueueable(Trigger.new));
}
✅ Trigger Frameworks (e.g. fflib)
Benefits:
Standardized pattern.
Centralized logic.
Manages recursion.
Easy to test.
Example (fflib):
trigger AccountTrigger on Account (before insert, before update) {
fflib_SObjectDomain.triggerHandler(AccountDomain.class);
}
public with sharing class AccountDomain extends fflib_SObjectDomain {
public AccountDomain(List<Account> records) {
super(records);
}
public override void onBeforeInsert() {
for (Account acc : (List<Account>) Records) {
acc.Description = 'Set by fflib';
}
}
}
✅ Custom Metadata for Configurable Triggers
Instead of hard-coding logic, store values in Custom Metadata.
Example:
Custom Metadata Country_Config__mdt
| Country__c | Default_Type__c |
| US | Customer |
| India | Partner |
Trigger logic:
Map<String, Country_Config__mdt> configs = new Map<String, Country_Config__mdt>(
[SELECT Country__c, Default_Type__c FROM Country_Config__mdt]
);
for (Account acc : Trigger.new) {
if (configs.containsKey(acc.BillingCountry)) {
acc.Type = configs.get(acc.BillingCountry).Default_Type__c;
}
}
✅ Error Handling in Triggers
Blocking Save
Use addError():
for (Account acc : Trigger.new) {
if (acc.Name == 'Test') {
acc.addError('Account name cannot be Test.');
}
}
Partial Save
Use Database.insert with allOrNone set to false:
Database.SaveResult[] results =
Database.insert(accList, false); // false allows partial success
for (Database.SaveResult sr : results) {
if (!sr.isSuccess()) {
for (Database.Error err : sr.getErrors()) {
System.debug(err.getMessage()); // Log errors for failed records
}
}
}
✅ Handling Recursion
Problem: Trigger fires → update record → fires trigger again → infinite loop.
Solution: Use a static variable:
public class AccountTriggerHandler {
public static Boolean alreadyExecuted = false;
public static void handleBeforeInsert(List<Account> accList) {
if (alreadyExecuted) return; // Exit if already executed in this transaction
alreadyExecuted = true; // Set flag to true
for (Account acc : accList) {
acc.Description = 'Handled only once';
}
}
}
✅ Platform Events from Triggers
Platform Events = Salesforce’s pub/sub. Use them to notify external systems.
Example: Platform Event: Order_Event__e
Trigger:
trigger OrderTrigger on Order (after insert) {
List<Order_Event__e> events = new List<Order_Event__e>();
for (Order ord : Trigger.new) {
events.add(new Order_Event__e(
OrderId__c = ord.Id,
Status__c = ord.Status
));
}
EventBus.publish(events); // Publish the events
}
✅ Using Custom Settings/Metadata for Trigger Control
Why?
Disable triggers in certain environments.
Turn logic on/off without deployment.
| Object_Name__c | Is_Active__c |
| Account | true |
Logic:
Trigger_Control__c setting = [
SELECT Is_Active__c
FROM Trigger_Control__c
WHERE Object_Name__c = 'Account'
LIMIT 1
];
if (!setting.Is_Active__c) {
return; // Exit trigger execution if not active
}
✅ Dynamic SOQL in Triggers
Build flexible SOQL at runtime:
String fieldName = 'Name';
String query = 'SELECT ' + fieldName + ' FROM Account WHERE Id = :accId';
List<SObject> results = Database.query(query);
Be cautious:
- Never concatenate user input into SOQL → avoid SOQL injection.
✅ Callouts from Triggers
The Problem:
- Triggers can’t make callouts synchronously.
Solution: Use @future or Queueable:
@future(callout=true)
public static void sendCallout(String accId) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://example.com/api');
req.setMethod('POST');
req.setBody('Account Id=' + accId);
Http http = new Http();
HttpResponse res = http.send(req);
}
When to Use Future vs Queueable
| Feature | Future | Queueable |
| Primitive args | ✔ | ✔ |
| Complex types | ✘ | ✔ |
| Chaining jobs | ✘ | ✔ |
| Simplicity | ✔ | ✘ |
For heavy logic → Queueable is best.
✅ Apex Trigger Learning Path
Follow these steps to go from beginner → expert:
✔ Understand syntax & context variables
✔ Learn bulk-safe patterns
✔ Practice using handler classes
✔ Master test classes
✔ Prevent recursion
✔ Study trigger frameworks
✔ Learn asynchronous methods
✔ Explore real projects & integrations