Salesforce · Session 88

Apex Triggers: Context Variables, Bulkification, and the Handler Pattern

Published 2026-07-03 · 12 min read · realsyllabus.com

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.

The most common before/after mistake: performing an explicit update DML operation on the triggering record inside an after trigger. That forces an extra DML statement and re-fires the trigger. If you only need to change fields on the triggering record, use before trigger — you can modify Trigger.new directly without any DML.

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:

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:

  1. System validation rules (required fields, field formats)
  2. Apex before triggers
  3. Custom validation rules
  4. Duplicate rules
  5. The record is saved to the database
  6. Apex after triggers
  7. Assignment rules
  8. Auto-response rules
  9. Workflow rules (Salesforce Classic)
  10. Escalation rules
  11. Record-Triggered Flows
  12. Entitlement rules
  13. Roll-up summary field updates trigger parent object DML
  14. Criteria-based sharing evaluation
Key implication: Record-Triggered Flows run after triggers. If your trigger depends on something a Flow would have set, it hasn't been set yet when the trigger runs. Build automation with the order in mind, not in isolation.

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:

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:

@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:

Start with Flow. Move to Apex triggers when Flow is the wrong tool — not when Apex feels more familiar.

Deployment checklist

Real Syllabus · Session 88

Session 89: Profiles vs Permission Sets →