Ask Krrish Contact Us
Home
Developer & Admin
Apex Triggers Mastery Asynchronous Apex SOQL & SOSL Governor Limits Flows & Process Builder
Advanced Topics
LWC Essentials Security & Sharing Managed Packages Deployment & CI/CD Integration Patterns
Interview Prep
Available Now
Debug Log Analyzer
Coming Soon
Org Comparator Soon
Resources About Ask Krrish Contact Us

Apex Triggers Interview Questions & Complete Guide — SFX Support

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 help?

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 — including 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 — calculations, data aggregations, or complex record relationships.
  • Prevent Inefficient Operations: Block certain operations or ensure data consistency.

Triggers vs. Declarative Tools

Always prefer declarative tools (Flows, Process Builder, Workflow Rules) first. Only resort to Apex Triggers when declarative options cannot meet requirements due to complexity or limitations.

  • Declarative Tools: 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

Apex — Trigger Syntax
trigger TriggerName on ObjectApiName (trigger_events) {
    // Your Apex code logic here
}
Apex — Simple Account Trigger
trigger AccountTrigger on Account (before insert, after update) {
    if (Trigger.isInsert && Trigger.isBefore) {
        System.debug('Executing before insert on Account');
    }
    if (Trigger.isUpdate && Trigger.isAfter) {
        System.debug('Executing after update on Account');
    }
}

2. Understanding Trigger Events

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

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

Fire before a record is saved to the database. Ideal for:

  • Validating or modifying field values on the record being processed.
  • Preventing a DML operation by adding errors.
  • Populating fields that should be set before saving.

Trigger.oldMap is not available in before insert — there is no old version of the record.

Apex — Before Insert Validation
trigger OpportunityValidationTrigger on Opportunity (before insert) {
    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)

Fire after a record has been saved to the database. Records now have an ID assigned (for inserts). Ideal for:

  • Accessing the record's newly assigned ID.
  • Performing operations on related records.
  • Sending emails or making callouts to external systems.
  • Creating or updating other records based on the current record's state.
Apex — After Insert: Create Default Contact
trigger AccountContactCreationTrigger on Account (after insert) {
    List<Contact> contactsToInsert = new List<Contact>();
    for (Account acc : Trigger.new) {
        contactsToInsert.add(new Contact(
            FirstName = 'Primary',
            LastName  = acc.Name + ' Contact',
            AccountId = acc.Id
        ));
    }
    if (!contactsToInsert.isEmpty()) {
        insert contactsToInsert;
    }
}

Order of Execution

Salesforce has a defined order of execution when a record is saved. Key stages:

  1. Original record loaded from database (or initialized for insert).
  2. New record values loaded from request, overwriting old values.
  3. System validation rules run.
  4. before triggers execute.
  5. System validation rules run again (if before trigger changed values).
  6. Duplicate rules run.
  7. Record saved to database (not yet committed).
  8. after triggers execute.
  9. Assignment rules, workflow rules, Flows, escalation rules execute.
  10. Roll-up summary fields calculated.
  11. Commit to database.

3. Trigger Context Variables

Context variables provide access to the records that caused the trigger to fire, as well as the execution context. These are crucial for writing dynamic and efficient trigger logic.

  • Trigger.new — List of new sObject record versions. Available in insert, update, undelete. Records can be modified in before triggers.
  • Trigger.old — List of old sObject record versions. Available in update and delete. Read-only.
  • Trigger.newMap — Map of IDs to new record versions. Available in before update, after insert, after update, after undelete.
  • Trigger.oldMap — Map of IDs to old record versions. Available in before update, after update, after delete. Ideal for comparing old vs. new values.
  • Trigger.isExecutingtrue if current context is a trigger. Useful for preventing recursion.
  • Trigger.isInsert / Trigger.isUpdate / Trigger.isDelete / Trigger.isUndelete — Identifies the DML operation type.
  • Trigger.isBefore / Trigger.isAfter — Identifies the execution phase.
Apex — Context Variables in Action
trigger MyAccountTrigger on Account (before insert, before update, after insert, after update) {
    if (Trigger.isBefore) {
        if (Trigger.isInsert) {
            for (Account acc : Trigger.new) {
                if (acc.BillingCity == null) {
                    acc.BillingCity = 'Unknown';
                }
            }
        } else if (Trigger.isUpdate) {
            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) {
            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) {
            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()) {
                EmailService.sendOwnerChangeNotification(accountsToNotify);
            }
        }
    }
}

4. Trigger Best Practices & Frameworks

One Trigger Per Object

Have only one trigger per sObject. Salesforce does not guarantee execution order between multiple triggers on the same object. Route all logic through a single trigger and delegate to a handler class.

Trigger Handler Pattern

The trigger acts as a dispatcher — calling methods in a separate Apex handler class based on context variables. This keeps the trigger lean and business logic testable and maintainable.

Apex — AccountTrigger.trigger (Dispatcher)
trigger AccountTrigger on Account (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    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);
    }
}
Apex — AccountTriggerHandler.cls
public class AccountTriggerHandler {

    private static Boolean bypassTrigger = false;

    public static void bypassTriggerExecution()  { bypassTrigger = true; }
    public static void resumeTriggerExecution()  { bypassTrigger = false; }
    private Boolean isBypassed() { return bypassTrigger; }

    public void onBeforeInsert(List<Account> newAccounts) {
        if (isBypassed()) return;
        for (Account acc : newAccounts) {
            if (acc.Industry == null) acc.Industry = 'Other';
        }
    }

    public void onBeforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
        if (isBypassed()) return;
        for (Account newAcc : newAccounts) {
            Account oldAcc = oldMap.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> oldMap) {
        if (isBypassed()) return;
        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> newMap) {
        if (isBypassed()) return;
        List<Task> tasks = new List<Task>();
        for (Account acc : newAccounts) {
            tasks.add(new Task(
                Subject  = 'Follow up with ' + acc.Name,
                WhatId   = acc.Id,
                Status   = 'Not Started',
                Priority = 'Normal'
            ));
        }
        if (!tasks.isEmpty()) insert tasks;
    }

    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
        if (isBypassed()) return;
        List<Contact> contactsToUpdate = new List<Contact>();
        Set<Id> changedAccountIds = new Set<Id>();

        for (Account newAcc : newAccounts) {
            Account oldAcc = oldMap.get(newAcc.Id);
            if (newAcc.BillingCity != oldAcc.BillingCity) {
                changedAccountIds.add(newAcc.Id);
            }
        }

        if (!changedAccountIds.isEmpty()) {
            Map<Id, Account> newMap2 = new Map<Id, Account>(newAccounts);
            for (Contact con : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :changedAccountIds]) {
                Account newAcc = newMap2.get(con.AccountId);
                con.MailingCity    = newAcc.BillingCity;
                con.MailingState   = newAcc.BillingState;
                con.MailingCountry = newAcc.BillingCountry;
                contactsToUpdate.add(con);
            }
            if (!contactsToUpdate.isEmpty()) update contactsToUpdate;
        }
    }

    public void onAfterDelete(List<Account> oldAccounts, Map<Id, Account> oldMap) {
        if (isBypassed()) return;
        for (Account acc : oldAccounts) {
            System.debug('Account deleted: ' + acc.Name + ' (' + acc.Id + ')');
        }
    }

    public void onAfterUndelete(List<Account> newAccounts, Map<Id, Account> newMap) {
        if (isBypassed()) return;
        for (Account acc : newAccounts) {
            System.debug('Account undeleted: ' + acc.Name + ' (' + acc.Id + ')');
        }
    }
}

Bulkification

Triggers fire for each record in a DML operation but must handle collections efficiently. Never put SOQL or DML inside for loops.

Apex — ❌ BAD vs ✅ GOOD: Bulkification
// ❌ BAD — SOQL inside loop, hits 100 query limit fast
for (Account acc : Trigger.new) {
    Contact c = [SELECT Id FROM Contact WHERE AccountId = :acc.Id LIMIT 1];
}

// ✅ GOOD — Collect IDs first, single query outside loop
Set<Id> accountIds = new Set<Id>();
for (Account acc : Trigger.new) {
    accountIds.add(acc.Id);
}

List<Contact> contacts = [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds];

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);
}

for (Account acc : Trigger.new) {
    List<Contact> related = contactsByAccountId.get(acc.Id);
    if (related != null) {
        System.debug(acc.Name + ' has ' + related.size() + ' contacts.');
    }
}

Error Handling

Use sObject.addError() in before triggers to prevent saving and show user-friendly errors in the Salesforce UI.

Apex — addError() in Before Trigger
trigger CaseStatusValidation on Case (before update) {
    for (Case newCase : Trigger.new) {
        Case oldCase = Trigger.oldMap.get(newCase.Id);
        if (oldCase.Status == 'Closed' && newCase.Status == 'Open') {
            newCase.addError('Cannot re-open a Closed case. Create a new case instead.');
        }
    }
}

Bypassing Triggers

For data migrations or integrations where trigger side effects are unwanted, use the static bypass pattern from the handler class.

Apex — Bypass in Data Migration Service
public class DataMigrationService {
    public static void importAccounts(List<Account> accountsToImport) {
        AccountTriggerHandler.bypassTriggerExecution();
        try {
            insert accountsToImport;
        } catch (DmlException e) {
            System.debug('Import error: ' + e.getMessage());
        } finally {
            AccountTriggerHandler.resumeTriggerExecution(); // Always resume
        }
    }
}

5. Handling Recursive Triggers

A recursive trigger occurs when a trigger performs a DML operation that re-fires the same trigger, creating an infinite loop and eventually a governor limit exception: "Maximum trigger depth exceeded."

Prevention with Static Boolean

The most common approach — a static boolean variable retains its value throughout a single transaction, so it can flag that the trigger has already run.

Apex — Static Flag Recursion Prevention
public class AccountTriggerHandler {
    private static Boolean hasRun = false;

    public static Boolean isFirstRun() {
        if (hasRun) return false;
        hasRun = true;
        return true;
    }

    public static void resetRunFlag() { hasRun = false; }

    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
        if (!isFirstRun()) {
            System.debug('Skipped — recursion prevention.');
            return;
        }

        List<Account> toUpdate = new List<Account>();
        for (Account newAcc : newAccounts) {
            Account oldAcc = oldMap.get(newAcc.Id);
            if (newAcc.Rating == 'Hot' && oldAcc.Rating != 'Hot') {
                newAcc.Description = 'Hot Account — Follow up required.';
                toUpdate.add(newAcc);
            }
        }
        if (!toUpdate.isEmpty()) update toUpdate; // Re-fires trigger, but isFirstRun() = false
    }
}

TriggerBypassUtil (Multi-Object)

For managing bypass flags across multiple objects and contexts, a centralized utility class is cleaner.

Apex — TriggerBypassUtil Class
public class TriggerBypassUtil {
    private static Set<String> handledTriggers = new Set<String>();

    public static Boolean bypass(String triggerName) {
        if (handledTriggers.contains(triggerName)) return true;
        handledTriggers.add(triggerName);
        return false;
    }

    public static void reset(String triggerName)  { handledTriggers.remove(triggerName); }
    public static void resetAll()                 { handledTriggers.clear(); }
}

// Usage in handler:
public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
    if (TriggerBypassUtil.bypass('AccountAfterUpdate')) return;

    List<Account> toUpdate = new List<Account>();
    for (Account acc : newAccounts) {
        if (acc.Description == null) {
            acc.Description = 'Auto-set description.';
            toUpdate.add(acc);
        }
    }
    if (!toUpdate.isEmpty()) update toUpdate; // Bypassed on re-fire
}

Static variables are reset at the start of each transaction. A new HTTP request or a separate System.runAs() block starts a fresh transaction, resetting all static variables.

6. Testing Apex Triggers

To deploy Apex to production, you must have unit tests with at least 75% code coverage. Write tests covering positive scenarios, negative scenarios, and bulk data operations (200 records).

Apex — Test Class Structure
@isTest
private class AccountTriggerTest {

    @isTest
    static void testBeforeInsertSetsIndustry() {
        List<Account> accounts = new List<Account>{
            new Account(Name = 'Test 1', Industry = null),
            new Account(Name = 'Test 2', Industry = 'Agriculture')
        };

        Test.startTest();
        insert accounts;
        Test.stopTest();

        List<Account> result = [SELECT Industry FROM Account WHERE Id IN :accounts];
        System.assertEquals('Other',       result[0].Industry, 'Null industry should default to Other.');
        System.assertEquals('Agriculture', result[1].Industry, 'Existing industry should not be overwritten.');
    }

    @isTest
    static void testAfterInsertCreatesOpportunity() {
        Account largeAcc = new Account(Name = 'Large Corp', NumberOfEmployees = 1500);
        Account smallAcc = new Account(Name = 'Small Co',   NumberOfEmployees = 50);

        Test.startTest();
        insert new List<Account>{ largeAcc, smallAcc };
        Test.stopTest();

        System.assertEquals(1, [SELECT COUNT() FROM Opportunity WHERE AccountId = :largeAcc.Id], 'Large account should have 1 opportunity.');
        System.assertEquals(0, [SELECT COUNT() FROM Opportunity WHERE AccountId = :smallAcc.Id], 'Small account should have 0 opportunities.');
    }

    @isTest
    static void testBulkInsert200Records() {
        List<Account> bulk = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            bulk.add(new Account(Name = 'Bulk ' + i, NumberOfEmployees = 1200));
        }

        Test.startTest();
        insert bulk;
        Test.stopTest();

        System.assertEquals(200, [SELECT COUNT() FROM Opportunity WHERE AccountId IN :bulk],
            'Should create 200 opportunities for bulk accounts.');
    }

    @isTest
    static void testBeforeDeletePrevention() {
        Account acc = new Account(Name = 'Big Corp', NumberOfEmployees = 200);
        insert acc;

        Test.startTest();
        Database.DeleteResult result = Database.delete(acc, false);
        Test.stopTest();

        System.assertFalse(result.isSuccess(), 'Delete should fail for large account.');
        System.assert(result.getErrors()[0].getMessage().contains('Cannot delete accounts with more than 100 employees.'));
    }

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

        Account acc = new Account(Name = 'Original Name');
        insert acc;

        Test.startTest();
        System.runAs(testUser) {
            acc.Name = 'New Name';
            Database.SaveResult sr = Database.update(acc, false);
            System.assertFalse(sr.isSuccess(), 'Non-admin should not rename account.');
            System.assert(sr.getErrors()[0].getMessage().contains('cannot be changed by non-admin'));
        }
        Test.stopTest();

        System.assertEquals('Original Name', [SELECT Name FROM Account WHERE Id = :acc.Id].Name);
    }
}

7. Advanced Trigger Concepts

Asynchronous Processing

For CPU-intensive operations, external callouts, or anything that exceeds synchronous governor limits, move logic into asynchronous Apex.

  • @future methods: Best for simple, single-fire async tasks like callouts. Cannot accept sObjects directly — pass IDs and query inside. Limited to 10 future calls per transaction.
  • Queueable Apex: More flexible — accepts sObjects as parameters, supports chaining up to 50 jobs.
  • Batch Apex: Designed for processing millions of records that exceed synchronous limits.
Apex — @future Callout from Trigger Handler
public class ExternalSyncService {
    @future(callout=true)
    public static void syncAccountDetails(Set<Id> accountIds) {
        List<Account> accounts = [SELECT Id, Name, Website, Phone FROM Account WHERE Id IN :accountIds];

        HttpRequest req = new HttpRequest();
        req.setMethod('POST');
        req.setEndpoint('https://api.externalcrm.com/sync');
        req.setHeader('Content-Type', 'application/json');

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

        try {
            HttpResponse res = new Http().send(req);
            if (res.getStatusCode() == 200) {
                System.debug('Sync successful.');
            } else {
                System.debug('Sync failed: ' + res.getStatusCode() + ' — ' + res.getBody());
            }
        } catch (System.CalloutException e) {
            System.debug('Callout error: ' + e.getMessage());
        }
    }
}
Apex — Queueable Job from Trigger Handler
public class AccountRevenueCalculator implements Queueable {
    private List<Account> accounts;

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

    public void execute(QueueableContext context) {
        List<Account> toUpdate = new List<Account>();
        for (Account acc : accounts) {
            Decimal total = 0;
            for (Opportunity opp : [
                SELECT Amount FROM Opportunity
                WHERE AccountId = :acc.Id AND StageName = 'Closed Won'
            ]) {
                if (opp.Amount != null) total += opp.Amount;
            }
            acc.AnnualRevenue = total;
            toUpdate.add(acc);
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}

// Enqueue from handler (onAfterInsert):
// System.enqueueJob(new AccountRevenueCalculator(eligibleAccounts));

Utility Helper Classes

Create small, reusable utility classes for common tasks — email sending, logging, validation, data transformation. This keeps your handler class focused and your code modular.

Apex — CustomEmailService Utility
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 });
    }
}

8. Conclusion & Next Steps

You've now covered the essential concepts of Apex Triggers — from trigger events and context variables to handler patterns, recursion prevention, testing, and advanced async processing. Always remember: prefer declarative solutions first, and reach for Apex Triggers only when declarative tools cannot meet the requirement.

Key Takeaways

  • Declarative First: Always try Flows, Process Builder, or Workflow Rules before writing trigger code.
  • One Trigger Per Object: Delegate logic to a Trigger Handler class — keep the trigger file as a thin dispatcher.
  • Bulkify Everything: Never put SOQL or DML inside loops. Handle collections in a single operation.
  • Prevent Recursion: Use static boolean variables or a TriggerBypassUtil class.
  • Test Thoroughly: Cover positive, negative, and bulk (200 record) scenarios with at least 75% code coverage.
  • Use Async for Heavy Work: Move callouts, CPU-intensive logic, and large DML into @future, Queueable, or Batch Apex.
  • Build Utility Classes: Keep handler classes lean with reusable email, logging, and validation utilities.

Practice Exercises

  1. Contact Name Normalization: before insert / before update on Contact — ensure FirstName and LastName are stored in Proper Case. Add error if LastName is blank.
  2. Account → Contact Address Sync: after update on Account — when BillingAddress changes, update MailingAddress on all related Contacts. Must be bulkified.
  3. Case Deletion Prevention: before delete on Case — prevent deletion if Status is 'Closed' or 'Escalated'.
  4. Opportunity Stage Rollup: On Opportunity after insert / after update — when moved to 'Closed Won', update a custom field Last_Won_Opportunity_Date__c on the related Account.
  5. External Data Sync (Advanced): after insert / after update on a custom object — when a field changes to 'Approved', make an async callout to an external API. Handle callout errors.

Recommended Resources

Get Expert Help

Independent community initiative. Not affiliated with Salesforce.com, Inc.