If you have written a trigger that works perfectly in a sandbox and throws a governor limit exception in production, you already know the problem. It isn't your logic — it's the environment. Sandbox runs involve a handful of records. Production DML operations can involve 200 at a time, inside a transaction that has already consumed limits from other automation.
This session covers Apex trigger fundamentals: the before/after distinction, context variables, the order of execution, bulkification, and the handler class pattern. These are the building blocks. Getting them right prevents every category of production trigger failure.
What an Apex trigger is
A trigger is Apex code that executes automatically when a DML operation — Insert, Update, Delete, or Undelete — fires on a Salesforce object. Unlike Flow, a trigger runs as code. That means access to the full Apex language, all of your custom logic, and the entire Salesforce API. It also means full responsibility for governor limits, bulk behaviour, and long-term maintainability.
Triggers are defined in the developer console or your IDE with a syntax like:
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
// logic here
}
The declaration names the object and the list of triggering events. A trigger can listen to multiple events simultaneously.
Before triggers vs after triggers
The most fundamental distinction in trigger design is before vs after.
Before triggers fire before the record is written to the database. The record exists in memory but has not yet been saved. This is where you modify field values on the triggering records themselves — because you don't need an additional DML statement to write those changes back. Salesforce commits whatever state the before-trigger leaves the record in.
After triggers fire after the record has been committed to the database. The record now has an Id. This is where you query related records, update child or related objects, or perform operations that depend on the record being persisted first.
Context variables
Inside a trigger, Salesforce provides a set of context variables that tell you exactly what is happening and give you access to the records:
- Trigger.new — List of new record versions. Available on insert and update.
- Trigger.old — List of record versions before the change. Available on update and delete.
- Trigger.newMap — Map of Id → new record. Available on update and after insert (when records have Ids).
- Trigger.oldMap — Map of Id → old record. Available on update and delete.
- Trigger.isInsert / Trigger.isUpdate / Trigger.isDelete / Trigger.isUndelete — Boolean flags for the DML operation type.
- Trigger.isBefore / Trigger.isAfter — Boolean flags for the phase.
- Trigger.size — Number of records in the current batch.
You use these variables to route logic. A pattern like if (Trigger.isBefore && Trigger.isInsert) lets you isolate code to the exact scenario it's meant for.
Order of execution
Triggers don't run in isolation. When a DML operation fires, Salesforce runs a defined sequence of automation. Knowing the order matters when your org has multiple automation types on the same object:
- System validation rules (required fields, field formats)
- Apex before triggers
- Custom validation rules
- Duplicate rules
- The record is saved to the database
- Apex after triggers
- Assignment rules
- Auto-response rules
- Workflow rules (Salesforce Classic)
- Escalation rules
- Record-Triggered Flows
- Entitlement rules
- Roll-up summary field updates trigger parent object DML
- Criteria-based sharing evaluation
Governor limits
Salesforce is a multi-tenant platform. Governor limits prevent one org's code from consuming resources that affect every other org on the same infrastructure. Every transaction — the chain of operations that starts with a single DML event — shares a fixed pool of resources:
- 100 SOQL queries per synchronous transaction
- 150 DML statements per transaction
- 50,000 rows retrieved by SOQL
- 12 MB heap size
- 10 seconds CPU time
These limits apply to the entire transaction — not just your trigger. If a Flow that runs before your trigger already used 60 SOQL queries, you have 40 remaining when your trigger executes. This is why triggers that pass all sandbox tests can fail in production, where other automation is also running.
Bulkification: the rule most violated
Salesforce processes DML operations in batches of up to 200 records. Your trigger must handle 200 records as efficiently as it handles 1 record. A non-bulkified trigger puts SOQL queries or DML statements inside a loop:
// WRONG — governor limit violation waiting to happen
for (Account a : Trigger.new) {
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :a.Id];
// 1 SOQL query × up to 200 records = up to 200 queries
// The limit is 100. This fails on the 101st record.
}
The correct pattern collects all the data you need first, then processes it:
// CORRECT — bulkified pattern
Set<Id> accountIds = new Set<Id>();
for (Account a : Trigger.new) {
accountIds.add(a.Id);
}
// One SOQL query for all accounts in the batch
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
// Loop through trigger records referencing the pre-fetched map
List<Account> toUpdate = new List<Account>();
for (Account a : Trigger.new) {
List<Contact> contacts = contactsByAccount.get(a.Id);
// process...
}
// One DML statement for all updates
if (!toUpdate.isEmpty()) update toUpdate;
The pattern: collect all IDs → one SOQL outside the loop → map the results → loop through trigger records referencing the map → one DML at the end. Never SOQL or DML inside the loop.
One trigger per object
You can technically write multiple triggers on the same object. Don't. The order in which multiple triggers on the same object fire is not guaranteed. You cannot control it, and it can change between deployments. Multiple triggers on the same object produce inconsistent, unmaintainable behaviour.
The rule: one trigger file per object. All routing happens inside that trigger file — if this is a before-insert, call X; if this is an after-update, call Y. The logic lives in handler classes. The trigger file is the routing layer only.
The handler class pattern
A clean trigger file looks like this:
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
AccountTriggerHandler handler = new AccountTriggerHandler();
if (Trigger.isBefore) {
if (Trigger.isInsert) handler.onBeforeInsert(Trigger.new);
if (Trigger.isUpdate) handler.onBeforeUpdate(Trigger.new, Trigger.oldMap);
}
if (Trigger.isAfter) {
if (Trigger.isInsert) handler.onAfterInsert(Trigger.new);
if (Trigger.isUpdate) handler.onAfterUpdate(Trigger.new, Trigger.oldMap);
}
}
The handler class (AccountTriggerHandler.cls) contains all the actual logic. Methods are named after the events they handle. Each method is independently testable. The trigger file stays clean and never needs modification when business logic changes — only the handler does.
Preventing recursion
When a trigger performs a DML operation that could re-trigger the same trigger, you have a recursion risk. The transaction won't loop infinitely — Salesforce has recursion protection — but it can fire your trigger more times than intended, consuming resources and producing incorrect results.
The standard prevention pattern uses a static Boolean flag:
public class TriggerHelper {
public static Boolean hasRun = false;
}
// At the start of the handler method:
if (TriggerHelper.hasRun) return;
TriggerHelper.hasRun = true;
Static variables persist for the life of the transaction and reset to their initial value at the start of the next transaction. Setting hasRun = true on first execution prevents re-entry for the remainder of the transaction.
Testing Apex triggers
Salesforce requires 75% test coverage to deploy Apex to production. This is a minimum threshold, not a target. Tests that exist to reach the percentage but don't assert anything are worse than useless — they pass while the logic is broken.
Write tests that:
- Insert or update 200 records — not 1. Your trigger must handle bulk scenarios.
- Call
System.assertEqualsto verify actual outcomes — not just that no exception was thrown. - Cover positive cases (logic should fire) and negative cases (logic should not fire).
- Use
Test.startTest()andTest.stopTest()to reset governor limits between setup and the code under test.
@isTest
static void testBulkAccountUpdate() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test Account ' + i));
}
Test.startTest();
insert accounts;
Test.stopTest();
// Query and assert the expected outcome for all 200 records
List<Account> results = [SELECT Id, YourCustomField__c FROM Account WHERE Name LIKE 'Test Account%'];
System.assertEquals(200, results.size());
for (Account a : results) {
System.assertNotEquals(null, a.YourCustomField__c, 'Custom field should be set');
}
}
When to use a trigger vs a Flow
Not every automation needs a trigger. Record-Triggered Flows handle most field updates, cross-object updates, and process automation without code. Use a trigger when:
- You need to make an HTTP callout to an external system (must be async from a trigger).
- Your logic requires complex exception handling or transaction control.
- Bulk processing at scale that exceeds what Flow can handle cleanly.
- Logic too complex for Flow to express clearly and maintainably.
Start with Flow. Move to Apex triggers when Flow is the wrong tool — not when Apex feels more familiar.
Deployment checklist
- One trigger per object, using the handler class pattern.
- No SOQL or DML inside loops — anywhere in the trigger or handler.
- Before vs after phases used correctly (field updates in before, related object updates in after).
- 75%+ test coverage with assertions that verify actual logic at bulk scale (200 records).
- Recursion prevention in place where the trigger's DML could re-trigger itself.
- No hardcoded IDs — Record Type IDs, User IDs, Profile IDs differ between orgs and sandboxes.
Real Syllabus · Session 88
Session 89: Profiles vs Permission Sets →