Salesforce Apex Triggers Interview Questions — 34 Real Scenarios with Code & Answers (2025)

📅  Apex Trigger
Salesforce Apex Triggers — Complete Interview Guide

Salesforce Apex Triggers — Complete Interview Questions Guide

Apex Triggers · 38 Questions Covered
Basic
Q
Write a trigger on Contact — when a Contact is inserted, an email should be sent to the Contact's email ID using a specified template.
📌 Concept

When a new Contact is inserted: (1) Find the Email Template by DeveloperName, (2) Loop through contacts skipping null emails, (3) Build SingleEmailMessage with setTemplateId() and setTargetObjectId(), (4) Send all in one call.

Trigger + Helper
trigger ContactWelcomeTrigger on Contact (after insert) {
    ContactWelcomeHelper.sendWelcomeEmail(Trigger.new);
}

public class ContactWelcomeHelper {
    public static void sendWelcomeEmail(List<Contact> newContacts) {
        EmailTemplate template = [
            SELECT Id FROM EmailTemplate
            WHERE DeveloperName = 'Contact_Welcome_Template' LIMIT 1
        ];
        List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>();
        for (Contact con : newContacts) {
            if (con.Email == null) continue;
            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setTemplateId(template.Id);
            mail.setTargetObjectId(con.Id);
            mail.setSaveAsActivity(false);
            emailList.add(mail);
        }
        if (!emailList.isEmpty()) Messaging.sendEmail(emailList);
    }
}
🎯 Interview Questions
Why after insert and not before insert?
In before insert, the Contact has no Id yet. setTargetObjectId(con.Id) needs the Id which only exists after the record is saved.
Why query template by DeveloperName and not Id?
Ids differ between sandbox and production. DeveloperName stays consistent across all environments.
Is this bulkified?
Yes. Template queried once outside loop. All emails collected in List. Single sendEmail() call. No SOQL/DML inside loop.
🏆 One-Liner Answer

"I wrote an after insert trigger on Contact that queries the Email Template by DeveloperName, builds SingleEmailMessage objects using setTemplateId and setTargetObjectId, and sends all in one Messaging.sendEmail() call — fully bulkified."


Intermediate
Q
Write a trigger on Contact to prevent duplicate records based on Contact Email and Contact Phone.
📌 Concept

Use before insert, before update to block before save. Collect all incoming Emails & Phones into Sets, fire one SOQL using OR, then use field-level addError() to block the specific duplicate field.

Trigger + Helper
trigger ContactDuplicateTrigger on Contact (before insert, before update) {
    ContactDuplicateHelper.checkDuplicates(Trigger.new, Trigger.oldMap);
}

public class ContactDuplicateHelper {
    public static void checkDuplicates(List<Contact> newContacts, Map<Id, Contact> oldMap) {
        Set<String> emails = new Set<String>();
        Set<String> phones = new Set<String>();
        for (Contact con : newContacts) {
            if (oldMap != null) {
                Contact old = oldMap.get(con.Id);
                if (con.Email == old.Email && con.Phone == old.Phone) continue;
            }
            if (con.Email != null) emails.add(con.Email);
            if (con.Phone != null) phones.add(con.Phone);
        }
        if (emails.isEmpty() && phones.isEmpty()) return;
        List<Contact> existing = [SELECT Id, Email, Phone FROM Contact
            WHERE Email IN :emails OR Phone IN :phones];
        Set<String> exEmails = new Set<String>();
        Set<String> exPhones = new Set<String>();
        for (Contact c : existing) {
            if (c.Email != null) exEmails.add(c.Email);
            if (c.Phone != null) exPhones.add(c.Phone);
        }
        for (Contact con : newContacts) {
            if (con.Email != null && exEmails.contains(con.Email))
                con.Email.addError('Duplicate found! This Email already exists.');
            if (con.Phone != null && exPhones.contains(con.Phone))
                con.Phone.addError('Duplicate found! This Phone already exists.');
        }
    }
}
🎯 Interview Questions
Why before insert, before update?
We want to prevent the record from saving. addError() in before context stops DML completely. In after context — record is already saved, too late.
Why field-level addError() instead of record-level?
con.Email.addError() highlights the specific field causing the issue — much better UX than a generic record-level error.
Won't a Contact duplicate itself on update?
No — the oldMap check skips the Contact if Email and Phone haven't changed. So a contact never flags itself.
🏆 One-Liner Answer

"I wrote a before insert, before update trigger that collects Emails and Phones into Sets, fires a single SOQL using OR, and calls field-level addError() to block duplicates. For updates, oldMap comparison ensures a contact never flags itself. Fully bulkified."


Intermediate
Q
Write a trigger on Task — only System Administrator users should be able to delete a Task.
Trigger + Helper
trigger TaskDeleteTrigger on Task (before delete) {
    TaskDeleteHelper.preventUnauthorizedDelete(Trigger.old);
}

public class TaskDeleteHelper {
    public static void preventUnauthorizedDelete(List<Task> oldTasks) {
        String profileName = [
            SELECT Name FROM Profile
            WHERE Id = :UserInfo.getProfileId() LIMIT 1
        ].Name;
        if (!profileName.trim().equalsIgnoreCase('System Administrator')) {
            for (Task t : oldTasks)
                t.addError('Only System Administrators can delete Tasks.');
        }
    }
}
🎯 Interview Questions
Why Trigger.old and not Trigger.new?
In delete context, Trigger.new is always null. Trigger.old holds the records about to be deleted.
Alternative approach?
Use FeatureManagement.checkPermission('Can_Delete_Task') with a Custom Permission assigned to System Admin. More flexible — no code change needed if another profile needs delete access.
🏆 One-Liner Answer

"I wrote a before delete trigger on Task that queries the running user's Profile Name using UserInfo.getProfileId() once outside the loop, and if not System Administrator, calls addError() on every task in Trigger.old. As an alternative, I'd prefer Custom Permission for better flexibility."


Basic
Q
Write a trigger on Account — when an Account is inserted, automatically populate the Shipping Address with the Billing Address.
Trigger + Helper
trigger AccountAddressTrigger on Account (before insert) {
    AccountAddressHelper.copyBillingToShipping(Trigger.new);
}

public class AccountAddressHelper {
    public static void copyBillingToShipping(List<Account> newAccounts) {
        for (Account acc : newAccounts) {
            if (acc.BillingStreet != null || acc.BillingCity != null) {
                acc.ShippingStreet      = acc.BillingStreet;
                acc.ShippingCity       = acc.BillingCity;
                acc.ShippingState      = acc.BillingState;
                acc.ShippingCountry    = acc.BillingCountry;
                acc.ShippingPostalCode = acc.BillingPostalCode;
            }
        }
    }
}
🎯 Interview Questions
Why before insert? Why no DML needed?
In before insert, changes made directly to Trigger.new are automatically saved by Salesforce — no separate DML needed. Zero governor limit usage.
Can this be done without a trigger?
Yes — using a Before Save Record-Triggered Flow. Salesforce recommends declarative approach for simple field updates. But developer interviews expect Apex knowledge too.
🏆 One-Liner Answer

"I wrote a before insert trigger on Account that loops through Trigger.new and directly assigns all 5 Billing fields to Shipping. Since it's a before trigger, changes are saved automatically — zero SOQL, zero DML, most efficient approach possible."


Intermediate
Q
Write a trigger on Opportunity — when an Opportunity is inserted, an Opportunity Line Item should be inserted by default with any product from the Standard Pricebook.
📌 Concept

Must be after insert because OLI needs OpportunityId. Get Standard Pricebook Id, query one active PricebookEntry, build OLI with mandatory fields: OpportunityId, PricebookEntryId, Quantity, UnitPrice.

Trigger + Helper
trigger OpportunityLineItemTrigger on Opportunity (after insert) {
    OpportunityLineItemHelper.insertDefaultLineItem(Trigger.new);
}

public class OpportunityLineItemHelper {
    public static void insertDefaultLineItem(List<Opportunity> newOpps) {
        Id stdPBId = Test.isRunningTest() ? Test.getStandardPricebookId()
            : [SELECT Id FROM Pricebook2 WHERE IsStandard = true LIMIT 1].Id;

        List<PricebookEntry> pbeList = [
            SELECT Id, UnitPrice, Product2Id FROM PricebookEntry
            WHERE Pricebook2Id = :stdPBId AND IsActive = true LIMIT 1];
        if (pbeList.isEmpty()) return;
        PricebookEntry pbe = pbeList[0];

        List<OpportunityLineItem> oliList = new List<OpportunityLineItem>();
        for (Opportunity opp : newOpps) {
            OpportunityLineItem oli = new OpportunityLineItem();
            oli.OpportunityId    = opp.Id;
            oli.PricebookEntryId = pbe.Id;
            oli.Quantity         = 1;
            oli.UnitPrice        = pbe.UnitPrice;
            oliList.add(oli);
        }
        if (!oliList.isEmpty()) insert oliList;
    }
}
🎯 Interview Questions
Why after insert?
OLI has a mandatory lookup to Opportunity via OpportunityId. In before insert, the Opportunity has no Id yet. We also perform a separate DML (insert OLI) — only allowed in after context.
What is a PricebookEntry and why is it needed?
You cannot link a Product directly to an OLI. PricebookEntry is the junction between Product and Pricebook. OLI requires PricebookEntryId — mandatory field.
Why Test.isRunningTest() for Pricebook?
In test context, Salesforce blocks querying Standard Pricebook directly. Test.getStandardPricebookId() is the official way to get it in tests. Without this guard, test classes will fail.
🏆 One-Liner Answer

"I wrote an after insert trigger on Opportunity that queries an active PricebookEntry from the Standard Pricebook, builds OLI records with OpportunityId, PricebookEntryId, Quantity=1, and UnitPrice, and inserts all in one DML. Test.isRunningTest() handles the Standard Pricebook limitation in test context."


Intermediate
Q
Write a trigger on OpportunityLineItem — when a Line Item is created, create an Asset with the associated Account.
📌 Key Chain

OLI → Opportunity → Account. OLI has no direct AccountId — must query Opportunity first. Mandatory Asset fields: Name, AccountId, Product2Id, Status.

Trigger + Helper
trigger OLIAssetTrigger on OpportunityLineItem (after insert) {
    OLIAssetHelper.createAssets(Trigger.new);
}

public class OLIAssetHelper {
    public static void createAssets(List<OpportunityLineItem> newOLIs) {
        Set<Id> oppIds = new Set<Id>();
        for (OpportunityLineItem oli : newOLIs) oppIds.add(oli.OpportunityId);

        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Id, AccountId FROM Opportunity
            WHERE Id IN :oppIds AND AccountId != null]);

        List<Asset> assetList = new List<Asset>();
        for (OpportunityLineItem oli : newOLIs) {
            Opportunity opp = oppMap.get(oli.OpportunityId);
            if (opp == null) continue;
            Asset ast    = new Asset();
            ast.Name       = oli.Name;
            ast.Product2Id = oli.Product2Id;
            ast.AccountId  = opp.AccountId;
            ast.Quantity   = oli.Quantity  != null ? oli.Quantity  : 1;
            ast.Price      = oli.UnitPrice != null ? oli.UnitPrice : 0;
            ast.PurchaseDate = Date.today();
            ast.Status     = 'Purchased';
            assetList.add(ast);
        }
        if (!assetList.isEmpty()) insert assetList;
    }
}
🎯 Interview Questions
Why can't we get AccountId directly from OLI?
OLI has no direct AccountId field. The chain is OLI → Opportunity → Account. We must query Opportunity first to get AccountId — skipping this causes NullPointerException.
Why use Map instead of List for Opportunities?
Map gives O(1) lookup by Opportunity Id inside the loop. Using a List would require nested loops — bad practice and slower.
🏆 One-Liner Answer

"I wrote an after insert trigger on OLI that collects Opportunity Ids, queries them into a Map with AccountId, then builds Asset records using Name, AccountId from Opportunity, Product2Id, Quantity, UnitPrice, and Status=Purchased. All inserted in one DML. 1 SOQL, 1 DML, fully bulkified."


Intermediate
Q
Write a trigger on Lead — when a Lead is inserted, create a duplicate Lead with the same details.
📌 Critical Concern — Recursion!

When we insert the duplicate Lead, the same trigger fires again → infinite loop → governor limit hit. Must use a static boolean recursion guard.

Trigger + Helper
trigger LeadDuplicateTrigger on Lead (after insert) {
    LeadDuplicateHelper.createDuplicateLeads(Trigger.new);
}

public class LeadDuplicateHelper {
    private static Boolean isExecuting = false;  // Recursion guard

    public static void createDuplicateLeads(List<Lead> newLeads) {
        if (isExecuting) return;  // Already running — exit
        isExecuting = true;

        List<Lead> duplicates = new List<Lead>();
        for (Lead original : newLeads) {
            Lead dup = original.clone(false, true, false, false);
            dup.LastName = original.LastName + ' (Duplicate)';
            duplicates.add(dup);
        }
        if (!duplicates.isEmpty()) insert duplicates;
        isExecuting = false;
    }
}
🎯 Interview Questions
Why after insert?
We need the original Lead's Id to exist before cloning. Also we perform a separate DML (insert duplicate) — only allowed in after context.
What is the recursion risk and how is it fixed?
When we insert the duplicate Lead, the same trigger fires again → tries to create another duplicate → infinite loop. Static boolean isExecuting persists within the same transaction — second trigger invocation returns immediately.
What does clone(false, true, false, false) do?
Parameters: preserveId=false (new record), isDeepClone=true (copies all field values), preserveReadonlyTimestamps=false, preserveAutonumber=false. Copies all fields without copying the original Id.
🏆 One-Liner Answer

"I wrote an after insert trigger on Lead that uses clone(false,true,false,false) to copy all fields, appends (Duplicate) to LastName, and inserts all duplicates in one DML. The most critical part is the static boolean recursion guard — without it, inserting the duplicate fires the trigger again causing an infinite loop."


Advanced
Q
Write a trigger on Account — when an Account is updated, update all Opportunities' Stage to Closed Lost if the Opportunity was created more than 30 days ago and Stage is not Closed Won.
Trigger + Helper
trigger AccountOpportunityTrigger on Account (after update) {
    AccountOpportunityHelper.closeOldOpportunities(Trigger.new);
}

public class AccountOpportunityHelper {
    public static void closeOldOpportunities(List<Account> updatedAccounts) {
        Set<Id> accountIds = new Set<Id>();
        for (Account acc : updatedAccounts) accountIds.add(acc.Id);

        Date thirtyDaysAgo = Date.today().addDays(-30);

        List<Opportunity> oppsToUpdate = [
            SELECT Id, StageName FROM Opportunity
            WHERE AccountId IN :accountIds
            AND CreatedDate < :thirtyDaysAgo
            AND StageName != 'Closed Won'];

        if (oppsToUpdate.isEmpty()) return;
        for (Opportunity opp : oppsToUpdate) opp.StageName = 'Closed Lost';
        update oppsToUpdate;
    }
}
🎯 Interview Questions
Why CreatedDate < thirtyDaysAgo and not >?
We want Opps OLDER than 30 days. thirtyDaysAgo = March 29. CreatedDate < March 29 means created BEFORE March 29 — which means more than 30 days old. Using > would give Opps less than 30 days old.
Why filter StageName in SOQL instead of Apex?
Filtering in SOQL means we only pull records we actually need. Filtering in Apex after querying wastes SOQL rows against the 50,000 row governor limit.
🏆 One-Liner Answer

"I wrote an after update trigger on Account that collects Account Ids, calculates Date.today().addDays(-30), and queries Opportunities where CreatedDate < thirtyDaysAgo and StageName != Closed Won. Then sets StageName=Closed Lost and updates in one DML. 1 SOQL, 1 DML, fully bulkified."


Advanced
Q
Create a field on Account named Client Contact (Lookup to Contact). When an Account is inserted, create a Contact using the Account Name and set it as the Client Contact on the Account.
📌 Key Design — Two DML Operations

DML 1: Insert Contact. DML 2: Update Account with the new Contact Id. After inserting Contacts, Salesforce populates their Ids on the same object reference in memory — no re-query needed.

Trigger + Helper
trigger AccountClientContactTrigger on Account (after insert) {
    AccountClientContactHelper.createClientContact(Trigger.new);
}

public class AccountClientContactHelper {
    public static void createClientContact(List<Account> newAccounts) {
        List<Contact> contactsToInsert = new List<Contact>();
        for (Account acc : newAccounts) {
            Contact con = new Contact();
            con.LastName  = String.isNotBlank(acc.Name) ? acc.Name : 'Unknown';
            con.AccountId = acc.Id;
            contactsToInsert.add(con);
        }
        if (contactsToInsert.isEmpty()) return;
        insert contactsToInsert;  // DML 1 — Contact Ids now populated

        // Map Contact back to Account using con.AccountId
        Map<Id, Id> accountToContactMap = new Map<Id, Id>();
        for (Contact con : contactsToInsert)
            accountToContactMap.put(con.AccountId, con.Id);

        List<Account> accountsToUpdate = new List<Account>();
        for (Account acc : newAccounts) {
            if (accountToContactMap.containsKey(acc.Id)) {
                Account a            = new Account();
                a.Id                  = acc.Id;
                a.Client_Contact__c   = accountToContactMap.get(acc.Id);
                accountsToUpdate.add(a);
            }
        }
        if (!accountsToUpdate.isEmpty()) update accountsToUpdate;  // DML 2
    }
}
🎯 Interview Questions
Why two separate DML calls?
The Account update needs the Contact Id, which only exists after DML 1 (insert Contact). The order is mandatory — Contact first, then Account update with the Contact Id.
Why create a new Account object for update instead of reusing the trigger Account?
Trigger.new records in after context are read-only. We create a fresh Account object with just Id + Client_Contact__c — only that field gets updated, nothing else is touched.
🏆 One-Liner Answer

"I wrote an after insert trigger on Account that creates a Contact per Account using Account Name as LastName, inserts all Contacts (DML 1 — Ids now populated), maps Contact.AccountId to Contact.Id, then builds partial Account update objects and updates Client_Contact__c in DML 2. Zero SOQL, 2 DML, fully bulkified."


Advanced
Q
Write a trigger on OLI — when a Line Item is created, send an email to the Opportunity Account's Client Contact with product details: Product Name, Product Code, Unit Price, List Price, Discount.
📌 Data Chain

OLI → Opportunity → Account → Client_Contact__r (Contact → Email). Also need Product details via PricebookEntry.Product2. Trigger.new doesn't carry relationship fields — must re-query OLIs.

Trigger + Helper
trigger OLIEmailTrigger on OpportunityLineItem (after insert) {
    OLIEmailHelper.sendClientContactEmail(Trigger.new);
}

public class OLIEmailHelper {
    public static void sendClientContactEmail(List<OpportunityLineItem> newOLIs) {
        Set<Id> oppIds = new Set<Id>();
        for (OpportunityLineItem oli : newOLIs) oppIds.add(oli.OpportunityId);

        // Query Opportunity → Account → Client Contact
        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Id, Account.Name, Account.Client_Contact__c,
                   Account.Client_Contact__r.FirstName,
                   Account.Client_Contact__r.LastName,
                   Account.Client_Contact__r.Email
            FROM Opportunity WHERE Id IN :oppIds
            AND Account.Client_Contact__c != null
            AND Account.Client_Contact__r.Email != null]);

        // Re-query OLIs with Product details
        List<OpportunityLineItem> oliDetails = [
            SELECT Id, OpportunityId, PricebookEntry.Product2.Name,
                   PricebookEntry.Product2.ProductCode,
                   UnitPrice, ListPrice, Discount
            FROM OpportunityLineItem
            WHERE Id IN :new Map<Id, OpportunityLineItem>(newOLIs).keySet()];

        String userName = UserInfo.getFirstName() + ' ' + UserInfo.getLastName();
        List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>();

        for (OpportunityLineItem oli : oliDetails) {
            Opportunity opp = oppMap.get(oli.OpportunityId);
            if (opp == null) continue;
            String email = opp.Account.Client_Contact__r.Email;
            String name  = opp.Account.Client_Contact__r.FirstName + ' '
                         + opp.Account.Client_Contact__r.LastName;
            String body  = '<p>Hi <b>' + name + '</b>,</p>'
                + '<p>Your Order has been processed.</p>'
                + '<p>Product: ' + oli.PricebookEntry?.Product2?.Name + '<br/>'
                + 'Code: '    + oli.PricebookEntry?.Product2?.ProductCode + '<br/>'
                + 'Unit Price: ' + oli.UnitPrice + '<br/>'
                + 'List Price: ' + oli.ListPrice + '<br/>'
                + 'Discount: '   + oli.Discount  + '%</p>'
                + '<p>Thanks,<br/><b>' + userName + '</b></p>';
            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(new List<String>{email});
            mail.setSubject('Your Order Has Been Processed');
            mail.setHtmlBody(body);
            mail.setSaveAsActivity(false);
            emailList.add(mail);
        }
        if (!emailList.isEmpty()) Messaging.sendEmail(emailList);
    }
}
🎯 Interview Questions
Why re-query OLIs instead of using Trigger.new directly?
Trigger.new only contains fields directly on the OLI — no relationship fields. To get PricebookEntry.Product2.Name we need a SOQL query with relationship traversal.
Why use ?. safe navigation operator?
PricebookEntry or Product2 could be null. oli.PricebookEntry?.Product2?.Name returns null safely instead of throwing a NullPointerException — clean null safety pattern.
🏆 One-Liner Answer

"I wrote an after insert trigger on OLI that queries Opportunities with Account's Client_Contact__r in one SOQL, re-queries OLIs with Product details since Trigger.new doesn't carry relationship fields, builds HTML emails with product info and running user name from UserInfo, and sends all in one sendEmail() call. 2 SOQL, 0 DML, fully bulkified."


Basic
Q
Write a trigger on Account — when an Account is inserted, send an email to the Admin user with text: "An account has been created and name is [Account Name]".
Trigger + Helper
trigger AccountAdminEmailTrigger on Account (after insert) {
    AccountAdminEmailHelper.sendEmailToAdmin(Trigger.new);
}

public class AccountAdminEmailHelper {
    public static void sendEmailToAdmin(List<Account> newAccounts) {
        List<User> adminUsers = [
            SELECT Id, Email FROM User
            WHERE Profile.Name = 'System Administrator'
            AND IsActive = true LIMIT 1];
        if (adminUsers.isEmpty()) return;
        String adminEmail = adminUsers[0].Email;

        List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>();
        for (Account acc : newAccounts) {
            String accName = String.isNotBlank(acc.Name) ? acc.Name : 'Unknown Account';
            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(new List<String>{adminEmail});
            mail.setSubject('New Account Created — ' + accName);
            mail.setHtmlBody('<p>Hi Admin,</p><p>An account has been created and name is <b>' + accName + '</b>.</p>');
            mail.setSaveAsActivity(false);
            emailList.add(mail);
        }
        if (!emailList.isEmpty()) Messaging.sendEmail(emailList);
    }
}
🎯 Interview Questions
Why query Admin User outside the loop?
There is only one Admin (or a fixed set). Their email doesn't change per Account. Querying inside the loop would fire 200 SOQL queries for 200 Accounts — SOQL 101 exception.
Better production approach?
Store admin email in a Custom Label — no SOQL needed at all. Label.Admin_Notification_Email — no hardcoding, no governor limit usage.
🏆 One-Liner Answer

"I wrote an after insert trigger on Account that queries the active System Administrator's email once outside the loop, builds a SingleEmailMessage per Account with subject and HTML body containing the Account Name, and sends all in one sendEmail() call. Production enhancement: store admin email in a Custom Label to eliminate SOQL entirely."


Advanced
Q
Create a field on Account — Total Sales Quantities. When an OLI is added, update the Account's Total Sales Quantities with the sum of all OLI Quantities.
📌 Key Design — Full Recalculation

Don't just add new quantity — query ALL existing OLIs for that Account and sum from scratch. This is idempotent and handles retries/deletes correctly.

Trigger + Helper
trigger OLIQuantityTrigger on OpportunityLineItem
    (after insert, after update, after delete) {
    List<OpportunityLineItem> oliList = Trigger.isDelete ? Trigger.old : Trigger.new;
    OLIQuantityHelper.updateAccountTotalQuantities(oliList);
}

public class OLIQuantityHelper {
    public static void updateAccountTotalQuantities(List<OpportunityLineItem> oliList) {
        Set<Id> oppIds = new Set<Id>();
        for (OpportunityLineItem oli : oliList) oppIds.add(oli.OpportunityId);

        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Id, AccountId FROM Opportunity
            WHERE Id IN :oppIds AND AccountId != null]);

        Set<Id> accountIds = new Set<Id>();
        for (Opportunity opp : oppMap.values()) accountIds.add(opp.AccountId);
        if (accountIds.isEmpty()) return;

        // SUM all OLIs via AggregateResult — clean and performant
        Map<Id, Decimal> accountQtyMap = new Map<Id, Decimal>();
        for (Id id : accountIds) accountQtyMap.put(id, 0);

        for (AggregateResult ar : [
            SELECT Opportunity.AccountId accId, SUM(Quantity) total
            FROM OpportunityLineItem
            WHERE Opportunity.AccountId IN :accountIds AND Quantity != null
            GROUP BY Opportunity.AccountId]) {
            accountQtyMap.put((Id)ar.get('accId'), (Decimal)ar.get('total'));
        }

        List<Account> toUpdate = new List<Account>();
        for (Account acc : [SELECT Id, Total_Sales_Quantities__c
                FROM Account WHERE Id IN :accountIds]) {
            Decimal newTotal = accountQtyMap.get(acc.Id);
            if ((acc.Total_Sales_Quantities__c != null ? acc.Total_Sales_Quantities__c : 0) != newTotal) {
                Account a = new Account(); a.Id = acc.Id;
                a.Total_Sales_Quantities__c = newTotal; toUpdate.add(a);
            }
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why full recalculation instead of incremental addition?
Incremental addition is risky — if trigger fires twice (retry), quantity doubles. If OLI is deleted, total never decreases. Full recalculation queries all remaining OLIs fresh every time — always accurate and idempotent.
Why can't native Roll-Up Summary do this?
OLI → Opportunity → Account is two levels deep. Native Roll-Up Summary only spans one direct Master-Detail relationship. DLRS (Declarative Lookup Rollup Summary) is a no-code alternative for senior interviews.
🏆 One-Liner Answer

"I wrote an after insert/update/delete trigger on OLI using AggregateResult SUM(Quantity) GROUP BY AccountId — one SOQL gives accurate total per Account. I initialize all Accounts with 0 so deleted-OLI accounts get reset correctly, query current values, and only update changed Accounts. Full recalculation, idempotent, 3 SOQL, 1 DML."


Advanced
Q
Upload a PDF to Documents. Write a trigger on Lead — when a Lead is inserted, send an email with the PDF as an attachment to the Lead's email ID. Subject: Welcome. Body: Please find the attached PDF.
Trigger + Helper
trigger LeadWelcomeEmailTrigger on Lead (after insert) {
    LeadWelcomeEmailHelper.sendWelcomeEmail(Trigger.new);
}

public class LeadWelcomeEmailHelper {
    public static void sendWelcomeEmail(List<Lead> newLeads) {
        List<Document> docs = [
            SELECT Id, Name, Body, ContentType FROM Document
            WHERE DeveloperName = 'Welcome_Document'
            AND ContentType = 'application/pdf' LIMIT 1];
        if (docs.isEmpty()) return;

        Messaging.EmailFileAttachment att = new Messaging.EmailFileAttachment();
        att.setFileName(docs[0].Name + '.pdf');
        att.setBody(docs[0].Body);
        att.setContentType(docs[0].ContentType);

        List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>();
        for (Lead lead : newLeads) {
            if (lead.Email == null) continue;
            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(new List<String>{lead.Email});
            mail.setSubject('Welcome');
            mail.setHtmlBody('<p>Please find the attached PDF.</p>');
            mail.setFileAttachments(new List<Messaging.EmailFileAttachment>{att});
            mail.setSaveAsActivity(false);
            emailList.add(mail);
        }
        if (!emailList.isEmpty()) Messaging.sendEmail(emailList);
    }
}
🎯 Interview Questions
What is the Document object and how is it different from ContentVersion?
Document is a Classic Salesforce object storing files with a Body Blob field. ContentVersion is the modern Files approach using VersionData as Blob. For ContentVersion use VersionData instead of Body — everything else is the same.
Why build the attachment object outside the loop?
There is only one PDF — it doesn't change per Lead. Building it once outside the loop and reusing it for all emails means zero extra processing. 1 SOQL, 1 attachment object, for all 200 Leads.
🏆 One-Liner Answer

"I wrote an after insert trigger on Lead that queries the PDF from Documents by DeveloperName (not Id — works across environments), builds one EmailFileAttachment with setBody(Blob) outside the loop, and reuses it for all emails. Subject: Welcome, Body: Please find the attached PDF. 1 SOQL, 0 DML, fully bulkified."


Intermediate
Q
Write a trigger on Account — when Account Type changes, send an email to all Contacts with: Subject: Account Update Info. Body: Your account information has been updated successfully. Account Name: XYZ.
Trigger + Helper
trigger AccountTypeChangeTrigger on Account (after update) {
    AccountTypeChangeHelper.sendEmailOnTypeChange(Trigger.new, Trigger.oldMap);
}

public class AccountTypeChangeHelper {
    public static void sendEmailOnTypeChange(List<Account> updatedAccounts, Map<Id, Account> oldMap) {
        Map<Id, String> accountNameMap = new Map<Id, String>();
        for (Account acc : updatedAccounts) {
            if (acc.Type != oldMap.get(acc.Id).Type)
                accountNameMap.put(acc.Id, acc.Name);
        }
        if (accountNameMap.isEmpty()) return;

        List<Contact> contacts = [
            SELECT Id, FirstName, LastName, Email, AccountId FROM Contact
            WHERE AccountId IN :accountNameMap.keySet() AND Email != null];
        if (contacts.isEmpty()) return;

        List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>();
        for (Contact con : contacts) {
            String accName = accountNameMap.get(con.AccountId);
            String body = '<p>Hi <b>' + con.FirstName + '</b>,</p>'
                + '<p>Your account information has been updated successfully.</p>'
                + '<p>Account Name : <b>' + accName + '</b></p>';
            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(new List<String>{con.Email});
            mail.setSubject('Account Update Info');
            mail.setHtmlBody(body);
            mail.setSaveAsActivity(false);
            emailList.add(mail);
        }
        if (!emailList.isEmpty()) Messaging.sendEmail(emailList);
    }
}
🎯 Interview Questions
Why check acc.Type != oldMap.get(acc.Id).Type?
An Account update fires for ANY field change. Without this check, emails go out for every Account update — phone change, name change, etc. oldMap comparison ensures we only react to Type changes specifically.
Why store Account Name in a Map?
After the first loop, we query Contacts — not Accounts. We need Account Name inside the Contact loop but Account records aren't available there. Storing in Map during first loop = zero extra SOQL.
🏆 One-Liner Answer

"I wrote an after update trigger on Account that uses oldMap to detect Type change, stores Account Names in a Map, queries all Contacts with non-null email in one SOQL, and builds emails with Subject: Account Update Info and body including Account Name. All sent in one sendEmail() call. 1 SOQL, 0 DML, fully bulkified."


Intermediate
Q
Write a trigger on OpportunityLineItem — when a Line Item is deleted, delete the parent Opportunity as well.
Trigger + Helper
trigger OLIDeleteOpportunityTrigger on OpportunityLineItem (before delete) {
    OLIDeleteOpportunityHelper.deleteParentOpportunity(Trigger.old);
}

public class OLIDeleteOpportunityHelper {
    public static void deleteParentOpportunity(List<OpportunityLineItem> deletedOLIs) {
        Set<Id> oppIds = new Set<Id>();
        for (OpportunityLineItem oli : deletedOLIs)
            if (oli.OpportunityId != null) oppIds.add(oli.OpportunityId);
        if (oppIds.isEmpty()) return;

        List<Opportunity> oppsToDelete = [
            SELECT Id FROM Opportunity WHERE Id IN :oppIds];
        if (!oppsToDelete.isEmpty()) {
            try { delete oppsToDelete; }
            catch (DmlException e) { System.debug('Delete failed: ' + e.getMessage()); }
        }
    }
}
🎯 Interview Questions
Why before delete preferred?
In before delete, the OLI is not yet removed. If Opportunity deletion fails, the OLI deletion can also be rolled back — everything stays consistent in the same transaction.
What about remaining OLIs on the same Opportunity?
Key business decision — should we delete Opportunity only if it has NO remaining OLIs? Query remaining OLIs excluding the deleted ones and only delete Opps with no remaining items. Always clarify this requirement with the interviewer.
🏆 One-Liner Answer

"I wrote a before delete trigger on OLI that collects OpportunityIds from Trigger.old — since Trigger.new is null in delete context — queries those Opportunities and deletes them in one DML. Key design consideration: whether to delete only when no remaining OLIs exist. 1 SOQL, 1 DML, fully bulkified."


Advanced
Q
Write a trigger on Opportunity — when the Stage changes, send an email to the Account's Client Contact. Subject: Account Update Info. Body: Your account information has been updated successfully.
Trigger + Helper
trigger OpportunityStageChangeTrigger on Opportunity (after update) {
    OpportunityStageChangeHelper.sendEmailOnStageChange(Trigger.new, Trigger.oldMap);
}

public class OpportunityStageChangeHelper {
    public static void sendEmailOnStageChange(List<Opportunity> updatedOpps, Map<Id, Opportunity> oldMap) {
        Set<Id> accountIds = new Set<Id>();
        Map<Id, String> oppAccountMap = new Map<Id, String>();
        for (Opportunity opp : updatedOpps) {
            if (opp.StageName != oldMap.get(opp.Id).StageName && opp.AccountId != null) {
                accountIds.add(opp.AccountId);
                oppAccountMap.put(opp.AccountId, opp.Name);
            }
        }
        if (accountIds.isEmpty()) return;

        List<Account> accounts = [
            SELECT Id, Name, Client_Contact__r.FirstName,
                   Client_Contact__r.LastName, Client_Contact__r.Email
            FROM Account WHERE Id IN :accountIds
            AND Client_Contact__c != null
            AND Client_Contact__r.Email != null];
        if (accounts.isEmpty()) return;

        List<Messaging.SingleEmailMessage> emailList = new List<Messaging.SingleEmailMessage>();
        for (Account acc : accounts) {
            String body = '<p>Hi <b>' + acc.Client_Contact__r.FirstName + '</b>,</p>'
                + '<p>Your account information has been updated successfully.</p>'
                + '<p>Account Name : <b>' + acc.Name + '</b></p>'
                + '<p>Opportunity : <b>' + oppAccountMap.get(acc.Id) + '</b></p>';
            Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
            mail.setToAddresses(new List<String>{acc.Client_Contact__r.Email});
            mail.setSubject('Account Update Info');
            mail.setHtmlBody(body);
            mail.setSaveAsActivity(false);
            emailList.add(mail);
        }
        if (!emailList.isEmpty()) Messaging.sendEmail(emailList);
    }
}
🎯 Interview Questions
Why opp.StageName != oldMap.get(opp.Id).StageName?
Opportunity updates fire for ANY field change. Without this check, emails go out on every update. oldMap comparison ensures we only react to Stage changes specifically.
🏆 One-Liner Answer

"I wrote an after update trigger on Opportunity that uses oldMap to detect StageName changes, stores Opportunity Name in a Map, queries Accounts with Client_Contact__r in one SOQL, builds emails with Subject: Account Update Info, and sends all in one sendEmail(). 1 SOQL, 0 DML, fully bulkified."


Advanced
Q
Write a trigger on OLI — when a Line Item is created, auto-create a Quote linked to the Opportunity.
Trigger + Helper (Key Fields)
// Quote mandatory fields: Name, OpportunityId, Pricebook2Id, Status
// Check existing Quotes first to prevent duplicates (idempotency)

trigger OLICreateQuoteTrigger on OpportunityLineItem (after insert) {
    OLICreateQuoteHelper.createQuoteOnOLIInsert(Trigger.new);
}

public class OLICreateQuoteHelper {
    public static void createQuoteOnOLIInsert(List<OpportunityLineItem> newOLIs) {
        Set<Id> oppIds = new Set<Id>();
        for (OpportunityLineItem oli : newOLIs) oppIds.add(oli.OpportunityId);

        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Id, Name, Pricebook2Id, CloseDate, OwnerId FROM Opportunity
            WHERE Id IN :oppIds]);

        Set<Id> oppsWithQuotes = new Set<Id>();
        for (Quote q : [SELECT OpportunityId FROM Quote
                WHERE OpportunityId IN :oppIds]) oppsWithQuotes.add(q.OpportunityId);

        List<Quote> quotesToInsert = new List<Quote>();
        for (Opportunity opp : oppMap.values()) {
            if (oppsWithQuotes.contains(opp.Id)) continue;
            Quote q = new Quote(); q.Name = 'Quote - ' + opp.Name;
            q.OpportunityId = opp.Id; q.Status = 'Draft';
            q.Pricebook2Id = opp.Pricebook2Id; q.ExpirationDate = opp.CloseDate;
            q.OwnerId = opp.OwnerId; quotesToInsert.add(q);
        }
        if (!quotesToInsert.isEmpty()) insert quotesToInsert;
    }
}
🎯 Interview Questions
Why check for existing Quotes before inserting?
Every Account update would re-create the same Quotes if we don't check. Checking existing Quotes makes the trigger idempotent — running it multiple times gives the same result.
Why must Pricebook2Id on Quote match the Opportunity?
Pricebook mismatch causes a DML error. Quote must use the same Pricebook as its parent Opportunity — always copy Pricebook2Id from the Opportunity.
🏆 One-Liner Answer

"I wrote an after insert trigger on OLI that queries Opportunity details and existing Quotes in two SOQLs. For Opps without existing Quotes, I create Quote records with Name, Pricebook2Id matching the Opp, ExpirationDate=CloseDate, Status=Draft, and same OwnerId. Idempotent duplicate check prevents re-creation. 2 SOQL, 1 DML."


Advanced
Q
Create a field on Account (Asset Minimum Expiration Date). When Assets are inserted, updated, deleted, or undeleted — update the Account's minimum UsageEndDate across all Assets.
Trigger + Helper (MIN via AggregateResult)
trigger AssetMinExpirationTrigger on Asset
    (after insert, after update, after delete, after undelete) {
    List<Asset> aList = Trigger.isDelete ? Trigger.old : Trigger.new;
    AssetMinExpirationHelper.updateMinExpirationDate(aList);
}

public class AssetMinExpirationHelper {
    public static void updateMinExpirationDate(List<Asset> assetList) {
        Set<Id> accountIds = new Set<Id>();
        for (Asset ast : assetList) if (ast.AccountId != null) accountIds.add(ast.AccountId);
        if (accountIds.isEmpty()) return;

        Map<Id, Date> accountMinMap = new Map<Id, Date>();
        for (Id id : accountIds) accountMinMap.put(id, null);

        for (AggregateResult ar : [
            SELECT AccountId, MIN(UsageEndDate) minDate
            FROM Asset WHERE AccountId IN :accountIds AND UsageEndDate != null
            GROUP BY AccountId]) {
            accountMinMap.put((Id)ar.get('AccountId'), (Date)ar.get('minDate'));
        }

        List<Account> toUpdate = new List<Account>();
        for (Account acc : [SELECT Id, Asset_Minimum_Expiration_Date__c
                FROM Account WHERE Id IN :accountIds]) {
            Date newMin = accountMinMap.get(acc.Id);
            if (acc.Asset_Minimum_Expiration_Date__c != newMin) {
                Account a = new Account(); a.Id = acc.Id;
                a.Asset_Minimum_Expiration_Date__c = newMin; toUpdate.add(a);
            }
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why cover all 4 events?
Insert → new date might be minimum. Update → date changed. Delete → current minimum might be removed (min shifts up). Undelete → min might go back down. All 4 events can change the minimum.
Why initialize all Accounts with null before populating?
AggregateResult only returns rows where Assets exist. If all Assets deleted, no row returned — Account stays with old min date forever without initialization. Null initialization correctly clears the field.
🏆 One-Liner Answer

"I wrote an after insert/update/delete/undelete trigger on Asset. The helper collects Account Ids, initializes all to null, fires one AggregateResult SOQL using MIN(UsageEndDate) GROUP BY AccountId, queries current Account values, and only fires DML when the min date changed. All Assets deleted = field cleared to null. 2 SOQL, 1 DML, fully bulkified."


Advanced
Q
Create a field on Account (Total Quantity). Collect all OLI Quantities and populate the total on Account level. Handle insert, update, delete, and undelete.
📌 Same Pattern as Q12 — Extended to All 4 Events

Uses SUM(Quantity) GROUP BY Opportunity.AccountId AggregateResult. Initialize all Accounts with 0 before summing. Only update if value changed.

Core Logic
trigger OLITotalQuantityTrigger on OpportunityLineItem
    (after insert, after update, after delete, after undelete) {
    List<OpportunityLineItem> oliList = Trigger.isDelete ? Trigger.old : Trigger.new;
    OLITotalQuantityHelper.updateAccountTotalQuantity(oliList);
}
// Helper: same pattern as Q12 — SUM via AggregateResult, 3 SOQL, 1 DML
// Key: Initialize all Account Ids with 0 before populating
// Key: Only update if oldTotal != newTotal (prevent unnecessary DML)
🏆 One-Liner Answer

"I wrote an after insert/update/delete/undelete trigger on OLI. Full recalculation using SUM(Quantity) AggregateResult. Initialize all Accounts with 0 so accounts with no remaining OLIs get reset. Only fire DML when value changed. 3 SOQL, 1 DML, fully bulkified, idempotent."


Advanced
Q
Create a field on Account (Total Opportunity Amount). When a Contact is updated, collect all Opportunity Amounts and update the Account-level Total.
Trigger + Helper
trigger ContactOpportunityAmountTrigger on Contact (after update) {
    ContactOpportunityAmountHelper.updateTotalOppAmount(Trigger.new, Trigger.oldMap);
}

public class ContactOpportunityAmountHelper {
    public static void updateTotalOppAmount(List<Contact> updatedContacts, Map<Id, Contact> oldMap) {
        Set<Id> accountIds = new Set<Id>();
        for (Contact con : updatedContacts) {
            Contact oldCon = oldMap.get(con.Id);
            if (con.AccountId != null) accountIds.add(con.AccountId);
            // If AccountId changed — recalculate OLD Account too
            if (oldCon.AccountId != null && oldCon.AccountId != con.AccountId)
                accountIds.add(oldCon.AccountId);
        }
        if (accountIds.isEmpty()) return;

        Map<Id, Decimal> accountAmountMap = new Map<Id, Decimal>();
        for (Id id : accountIds) accountAmountMap.put(id, 0);

        for (AggregateResult ar : [
            SELECT AccountId, SUM(Amount) totalAmount
            FROM Opportunity
            WHERE AccountId IN :accountIds AND Amount != null
            GROUP BY AccountId]) {
            accountAmountMap.put((Id)ar.get('AccountId'), (Decimal)ar.get('totalAmount'));
        }

        List<Account> toUpdate = new List<Account>();
        for (Account acc : [SELECT Id, Total_Opportunity_Amount__c
                FROM Account WHERE Id IN :accountIds]) {
            Decimal newTotal = accountAmountMap.get(acc.Id);
            Decimal oldTotal = acc.Total_Opportunity_Amount__c != null ? acc.Total_Opportunity_Amount__c : 0;
            if (oldTotal != newTotal) {
                Account a = new Account(); a.Id = acc.Id;
                a.Total_Opportunity_Amount__c = newTotal; toUpdate.add(a);
            }
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why collect both old and new AccountId when AccountId changes?
If Contact moved from Account A → Account B, both accounts need recalculation. Without collecting oldAccountId, Account A's total would remain stale — never updated after the Contact was moved away.
🏆 One-Liner Answer

"I wrote an after update trigger on Contact that collects both old and new AccountIds (to handle Contact account changes), fires one AggregateResult SUM(Amount) SOQL, queries current Account values, and only updates when total changed. 2 SOQL, 1 DML, fully bulkified."


Advanced
Q
Write a trigger on Account — when an Account is updated, copy all Opportunity Line Items as Assets on that Account (same products, no duplicates).
📌 Duplicate Prevention — Unique Key Pattern

Build a unique key = AccountId + '_' + Product2Id to check existing Assets. Add new keys to the Set inside the loop to also prevent intra-batch duplicates.

Core Logic
trigger AccountAssetCopyTrigger on Account (after update) {
    AccountAssetCopyHelper.copyOLIsToAssets(Trigger.new, Trigger.oldMap);
}
// Helper Steps:
// 1. Collect Account Ids → Query Opportunities → Map<OppId, AccountId>
// 2. Query OLIs WHERE OpportunityId IN oppIds AND Product2Id != null
// 3. Query existing Assets → build Set of 'AccountId_Product2Id' keys
// 4. Loop OLIs → check key → skip if exists → add to assetList + add key to Set
// 5. Insert all Assets in one DML
// Result: 3 SOQL, 1 DML, idempotent, intra-batch dedup handled
🏆 One-Liner Answer

"I wrote an after update trigger on Account that queries Opportunities, OLIs with Product details, and existing Assets. I build a unique key Set using AccountId+'_'+Product2Id to prevent duplicates — also adding new keys inside the loop to prevent intra-batch duplicates. Assets created with Name, AccountId, Product2Id, Quantity, Price, Status=Purchased in one DML. 3 SOQL, 1 DML, idempotent."


Basic
Q
Write a trigger on Account — when BillingCity is updated, update all related Contacts' MailingCity with the new Account BillingCity.
Trigger + Helper
trigger AccountBillingCityTrigger on Account (after update) {
    AccountBillingCityHelper.syncMailingCity(Trigger.new, Trigger.oldMap);
}

public class AccountBillingCityHelper {
    public static void syncMailingCity(List<Account> updatedAccounts, Map<Id, Account> oldMap) {
        Map<Id, String> accountCityMap = new Map<Id, String>();
        for (Account acc : updatedAccounts) {
            if (acc.BillingCity != oldMap.get(acc.Id).BillingCity)
                accountCityMap.put(acc.Id, acc.BillingCity);
        }
        if (accountCityMap.isEmpty()) return;

        List<Contact> contacts = [
            SELECT Id, AccountId, MailingCity FROM Contact
            WHERE AccountId IN :accountCityMap.keySet()];
        if (contacts.isEmpty()) return;

        List<Contact> toUpdate = new List<Contact>();
        for (Contact con : contacts) {
            String newCity = accountCityMap.get(con.AccountId);
            if (con.MailingCity != newCity) { con.MailingCity = newCity; toUpdate.add(con); }
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why check con.MailingCity != newCity before updating?
If Contact's MailingCity already matches the new BillingCity, no DML needed. Prevents unnecessary Contact updates, LastModifiedDate changes, and Contact after update trigger from firing needlessly.
What if BillingCity is set to null?
Apex != comparison handles null correctly. null != 'Mumbai' = true → Contact MailingCity gets cleared. The null propagates correctly to Contact.
🏆 One-Liner Answer

"I wrote an after update trigger on Account that uses oldMap to detect BillingCity changes, stores new city in a Map, queries all Contacts in one SOQL, compares MailingCity before updating — only changed Contacts get DML. 1 SOQL, 1 DML, fully bulkified."


Intermediate
Q
Create a Client Contact field on Opportunity (Lookup to Contact). When Opportunity Client Contact is updated, update the Account's Client Contact with the same value.
Trigger + Helper
trigger OpportunityClientContactTrigger on Opportunity (after update) {
    OpportunityClientContactHelper.syncClientContact(Trigger.new, Trigger.oldMap);
}

public class OpportunityClientContactHelper {
    public static void syncClientContact(List<Opportunity> updatedOpps, Map<Id, Opportunity> oldMap) {
        Map<Id, Id> accountContactMap = new Map<Id, Id>();
        for (Opportunity opp : updatedOpps) {
            if (opp.Client_Contact__c != oldMap.get(opp.Id).Client_Contact__c && opp.AccountId != null)
                accountContactMap.put(opp.AccountId, opp.Client_Contact__c);
        }
        if (accountContactMap.isEmpty()) return;

        List<Account> toUpdate = new List<Account>();
        for (Account acc : [SELECT Id, Client_Contact__c FROM Account
                WHERE Id IN :accountContactMap.keySet()]) {
            Id newContact = accountContactMap.get(acc.Id);
            if (acc.Client_Contact__c != newContact) {
                Account a = new Account(); a.Id = acc.Id;
                a.Client_Contact__c = newContact; toUpdate.add(a);
            }
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🏆 One-Liner Answer

"I wrote an after update trigger on Opportunity that uses oldMap to detect Client_Contact__c changes, stores new Contact Id in a Map keyed by AccountId, queries Account current values, and only updates if different. If multiple Opps on same Account change, last one wins — clarify priority with business. 1 SOQL, 1 DML, fully bulkified."


Advanced
Q
Create a Serial No field on OLI (Text). Auto-populate incrementing values when OLIs are added: 1, 2, 3. If OLI 2 is deleted and a new one added, it must be 4 — never reuse deleted numbers.
📌 Key Design — Counter on Opportunity

Never use MAX(Serial_No__c) — it resets on delete. Store the counter on the parent Opportunity in a Last_OLI_Serial_No__c field. Counter only goes up, never down.

Trigger + Helper
// Fields needed:
// OLI:         Serial_No__c (Text)
// Opportunity: Last_OLI_Serial_No__c (Number)

trigger OLISerialNoTrigger on OpportunityLineItem (before insert) {
    OLISerialNoHelper.assignSerialNumbers(Trigger.new);
}

public class OLISerialNoHelper {
    public static void assignSerialNumbers(List<OpportunityLineItem> newOLIs) {
        Set<Id> oppIds = new Set<Id>();
        for (OpportunityLineItem oli : newOLIs) oppIds.add(oli.OpportunityId);

        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Id, Last_OLI_Serial_No__c FROM Opportunity
            WHERE Id IN :oppIds]);

        Map<Id, Integer> counterMap = new Map<Id, Integer>();
        for (Opportunity opp : oppMap.values()) {
            counterMap.put(opp.Id, opp.Last_OLI_Serial_No__c != null
                ? Integer.valueOf(opp.Last_OLI_Serial_No__c) : 0);
        }

        for (OpportunityLineItem oli : newOLIs) {
            Integer counter = counterMap.get(oli.OpportunityId);
            counter++;
            oli.Serial_No__c = String.valueOf(counter);  // before insert — no DML needed
            counterMap.put(oli.OpportunityId, counter);   // update Map for next OLI in batch
        }

        List<Opportunity> toUpdate = new List<Opportunity>();
        for (Id oppId : counterMap.keySet()) {
            Opportunity o = new Opportunity(); o.Id = oppId;
            o.Last_OLI_Serial_No__c = counterMap.get(oppId); toUpdate.add(o);
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why store counter on Opportunity instead of querying MAX(Serial_No__c)?
MAX() resets when OLIs are deleted. OLI 1,2,3 → delete 3 → MAX=2 → next would be 3 again (wrong!). Opportunity counter is never touched by delete — only increments. Delete OLI 3 → counter stays 3 → next is 4. ✅
Why update counterMap inside the OLI loop?
If 3 OLIs insert at once on same Opportunity without updating the Map, all 3 get the same serial number (duplicate!). Updating the Map after each OLI gives sequential 4,5,6 in the same batch transaction.
🏆 One-Liner Answer

"I wrote a before insert trigger on OLI that queries the Opportunity counter, increments per OLI (updating the Map after each to handle bulk inserts correctly), sets Serial_No__c directly on Trigger.new — no OLI DML needed since it's before insert — then updates Opportunity counter in one DML. Counter never decreases on delete so sequence is always forward. 1 SOQL, 1 DML."


Advanced
Q
Create Total Quantity and Available Quantity fields on Product2. When an OLI is inserted, update, or deleted — deduct/adjust/restore the Product's Available Quantity accordingly.
📌 Three Different Scenarios

Insert: deduct full new quantity. Update: deduct only the difference (newQty - oldQty). Delete: restore full old quantity. Use System.TriggerOperation enum to handle each case cleanly.

Trigger + Helper
trigger OLIProductQuantityTrigger on OpportunityLineItem
    (after insert, after update, after delete) {
    OLIProductQuantityHelper.updateProductQuantity(
        Trigger.new, Trigger.old, Trigger.oldMap, Trigger.operationType);
}

public class OLIProductQuantityHelper {
    public static void updateProductQuantity(
        List<OpportunityLineItem> newOLIs, List<OpportunityLineItem> oldOLIs,
        Map<Id, OpportunityLineItem> oldMap, System.TriggerOperation opType) {

        Map<Id, Decimal> productChangeMap = new Map<Id, Decimal>();

        if (opType == System.TriggerOperation.AFTER_INSERT) {
            for (OpportunityLineItem oli : newOLIs)
                if (oli.Product2Id != null) addToMap(productChangeMap, oli.Product2Id, oli.Quantity != null ? oli.Quantity : 0);
        } else if (opType == System.TriggerOperation.AFTER_UPDATE) {
            for (OpportunityLineItem oli : newOLIs) {
                if (oli.Product2Id == null) continue;
                Decimal diff = (oli.Quantity != null ? oli.Quantity : 0)
                             - (oldMap.get(oli.Id).Quantity != null ? oldMap.get(oli.Id).Quantity : 0);
                if (diff != 0) addToMap(productChangeMap, oli.Product2Id, diff);
            }
        } else if (opType == System.TriggerOperation.AFTER_DELETE) {
            for (OpportunityLineItem oli : oldOLIs)
                if (oli.Product2Id != null) addToMap(productChangeMap, oli.Product2Id, -(oli.Quantity != null ? oli.Quantity : 0));
        }
        if (productChangeMap.isEmpty()) return;

        List<Product2> prods = [SELECT Id, Available_Quantity__c
            FROM Product2 WHERE Id IN :productChangeMap.keySet()];
        List<Product2> toUpdate = new List<Product2>();
        for (Product2 prod : prods) {
            Decimal curr = prod.Available_Quantity__c != null ? prod.Available_Quantity__c : 0;
            Decimal newAvail = Math.max(0, curr - productChangeMap.get(prod.Id));
            Product2 p = new Product2(); p.Id = prod.Id; p.Available_Quantity__c = newAvail;
            toUpdate.add(p);
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
    private static void addToMap(Map<Id, Decimal> m, Id id, Decimal qty) {
        if (!m.containsKey(id)) m.put(id, 0);
        m.put(id, m.get(id) + qty);
    }
}
🎯 Interview Questions
Why deduct only the DIFFERENCE on update?
On insert, we already deducted 50. If quantity changes 50→70, deducting full 70 again gives Available = 50-70 = -20 (wrong). Deducting only the diff (70-50=20) gives Available = 50-20 = 30 (correct).
Why Math.max(0, ...) guard?
Prevents negative stock. If someone adds OLI quantity exceeding available stock, Available is capped at 0 instead of going negative. In production, this should throw an error to block the insert.
🏆 One-Liner Answer

"I wrote an after insert/update/delete trigger on OLI using System.TriggerOperation enum. Insert deducts full quantity, update deducts only difference (newQty-oldQty), delete restores old quantity using negative values. All accumulated per Product in a Map using addToMap() helper. Math.max(0,...) prevents negative stock. 1 SOQL, 1 DML, fully bulkified."


Advanced
Q
Create a Student custom object with Private OWD. When a Student record is created, automatically share it with all users who have the 'Salesforce User' profile.
📌 Key Concept — Apex Managed Sharing

OWD=Private means only owner can see records. To share programmatically, create Student__Share records (auto-generated by Salesforce when OWD=Private). Use RowCause = Manual and Database.insert(false) for partial success to handle duplicate sharing gracefully.

Trigger + Helper
trigger StudentSharingTrigger on Student__c (after insert) {
    StudentSharingHelper.shareWithSalesforceUsers(Trigger.new);
}

public class StudentSharingHelper {
    public static void shareWithSalesforceUsers(List<Student__c> newStudents) {
        List<User> sfUsers = [
            SELECT Id FROM User
            WHERE Profile.Name = 'Salesforce User' AND IsActive = true];
        if (sfUsers.isEmpty()) return;

        List<Student__Share> shareRecords = new List<Student__Share>();
        for (Student__c student : newStudents) {
            for (User usr : sfUsers) {
                if (usr.Id == student.OwnerId) continue;  // Skip owner
                Student__Share sr = new Student__Share();
                sr.ParentId      = student.Id;
                sr.UserOrGroupId = usr.Id;
                sr.AccessLevel   = 'Read';
                sr.RowCause      = Schema.Student__Share.RowCause.Manual;
                shareRecords.add(sr);
            }
        }
        if (!shareRecords.isEmpty())
            Database.insert(shareRecords, false);  // false = partial success
    }
}
🎯 Interview Questions
What is the Student__Share object and where does it come from?
Salesforce auto-generates a Share object for every custom object when OWD is set to Private. Fields: ParentId (record to share), UserOrGroupId (who to share with), AccessLevel (Read/Edit), RowCause (why it's shared). Insert a record = grant access. Delete = revoke.
Why Database.insert(shareRecords, false)?
False = partial success mode. If one Share record fails (e.g. duplicate), others still insert. With regular insert (allOrNothing=true), one duplicate would roll back all successful shares.
Why skip the record Owner?
The record Owner already has Full Access automatically. Creating a Share record for them would cause a duplicate error.
🏆 One-Liner Answer

"I created Student__c with OWD=Private — which auto-generates Student__Share. I wrote an after insert trigger that queries active Salesforce User profile users in one SOQL, creates Student__Share records with ParentId, UserOrGroupId, AccessLevel=Read, RowCause=Manual — skipping the Owner. Database.insert(false) handles duplicate shares gracefully. 1 SOQL, 1 DML, fully bulkified."


Advanced
Q
Write a trigger on Asset — when an Asset is inserted into an Account, also create an Opportunity and one Opportunity Line Item linked to the Account.
📌 Two DML Operations — Order Matters

DML 1: Insert Opportunity. DML 2: Insert OLI (needs Opportunity Id). Cannot be combined. Use Map<AssetId, Opportunity> — after DML 1, Salesforce populates Opp Ids on the same object references in memory.

Core Logic
trigger AssetOpportunityTrigger on Asset (after insert) {
    AssetOpportunityHelper.createOpportunityAndOLI(Trigger.new);
}
// Helper Steps:
// 1. Collect Product Ids → Query Standard Pricebook (Test.isRunningTest() guard)
// 2. Query PricebookEntries → Map<Product2Id, PBE>
// 3. Build Opps → Map<AssetId, Opportunity> → Insert (DML 1, Ids populated)
// 4. Build OLIs using assetToOppMap.get(ast.Id).Id → Insert (DML 2)
// Result: 2 SOQL, 2 DML — order is mandatory (OLI needs Opp Id)
🎯 Interview Questions
Why two separate DML calls?
OLI requires OpportunityId which only exists after DML 1. Inserting OLI before Opportunity would fail with a DML exception — required field missing. The order is mandatory.
How do you get the Opportunity Id after DML 1 without re-querying?
After insert, Salesforce automatically populates the Id on the same Opportunity object reference stored in the Map. So assetToOppMap.get(ast.Id).Id is available immediately after DML 1 — no re-query needed.
🏆 One-Liner Answer

"I wrote an after insert trigger on Asset. The helper queries Standard Pricebook and PricebookEntries, builds one Opportunity per Asset in a Map, inserts them (DML 1 — Ids now populated on same references), then loops Assets to build OLIs using assetToOppMap.get(ast.Id).Id and inserts in DML 2. Two DML calls are mandatory — OLI needs Opp Id which only exists after DML 1. 2 SOQL, 2 DML."


Advanced
Q
Write a trigger on User — when a User is updated, send an email to the Manager with: total number of Accounts owned by the User, and per-Account Contact count. Format: Total Account: 3 | A-10, B-10, C-10.
Core Logic
trigger UserUpdateEmailTrigger on User (after update) {
    UserUpdateEmailHelper.sendManagerEmail(Trigger.new, Trigger.oldMap);
}
// Helper Steps:
// 1. Collect User Ids + Manager Ids (skip null ManagerId)
// 2. Query Manager details → Map<ManagerId, User>
// 3. Query Accounts owned by Users → Map<UserId, List<Account>>
// 4. AggregateResult COUNT(Id) GROUP BY AccountId → Map<AccountId, Integer>
// 5. Build HTML email per User showing: Total Accounts + table of Account→Contact count
// 3 SOQL, 0 DML, 1 sendEmail call

// Email Body:
// Hi [Manager Name],
// [User Name] has been updated. You have assigned [X] accounts.
// Total Account : 3
// | Account Name | No. of Contacts |
// | Ami Polymer  |       10        |
🎯 Interview Questions
Why use AggregateResult for Contact count?
AggregateResult with COUNT(Id) GROUP BY AccountId returns one row per Account with the count. Fetching all Contact records would use thousands of SOQL rows unnecessarily.
Why group Accounts by Map<UserId, List<Account>>?
Multiple Users can be updated in one transaction. Each User needs their own Account list for their specific email. Map grouping keeps them separate.
🏆 One-Liner Answer

"I wrote an after update trigger on User that queries Manager details, Accounts by OwnerId grouped in a Map, and Contact counts via AggregateResult COUNT(). Builds HTML email per User with Manager as recipient — showing total Accounts and per-Account Contact count table. 3 SOQL, 0 DML, 1 sendEmail call, fully bulkified."


Intermediate
Q
Create a Count field on User. When an Account is updated — increment Count by 1. When an Account is deleted — decrement Count by 1.
Trigger + Helper
trigger AccountUserCountTrigger on Account (after update, after delete) {
    if (Trigger.isUpdate) AccountUserCountHelper.incrementCount(Trigger.new);
    if (Trigger.isDelete) AccountUserCountHelper.decrementCount(Trigger.old);
}

public class AccountUserCountHelper {
    public static void incrementCount(List<Account> accounts) {
        Map<Id, Integer> userMap = buildCountMap(accounts);
        applyToUsers(userMap, true);
    }
    public static void decrementCount(List<Account> accounts) {
        Map<Id, Integer> userMap = buildCountMap(accounts);
        applyToUsers(userMap, false);
    }
    private static Map<Id, Integer> buildCountMap(List<Account> accounts) {
        Map<Id, Integer> m = new Map<Id, Integer>();
        for (Account acc : accounts) {
            if (acc.OwnerId == null) continue;
            if (!m.containsKey(acc.OwnerId)) m.put(acc.OwnerId, 0);
            m.put(acc.OwnerId, m.get(acc.OwnerId) + 1);
        }
        return m;
    }
    private static void applyToUsers(Map<Id, Integer> userMap, Boolean increment) {
        List<User> users = [SELECT Id, Count__c FROM User WHERE Id IN :userMap.keySet()];
        List<User> toUpdate = new List<User>();
        for (User usr : users) {
            Decimal curr = usr.Count__c != null ? usr.Count__c : 0;
            Integer change = userMap.get(usr.Id);
            User u = new User(); u.Id = usr.Id;
            u.Count__c = increment ? curr + change : Math.max(0, curr - change);
            toUpdate.add(u);
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why use one trigger with routing instead of two separate triggers?
One trigger per object is Salesforce best practice — prevents execution order conflicts. We route to different methods using Trigger.isUpdate and Trigger.isDelete inside the single trigger.
Why Math.max(0, ...) on decrement?
Prevents negative Count. If Count__c is already 0 or null when an Account is deleted, 0-1=-1 would be wrong. Math.max(0, -1) = 0 — always keeps count non-negative.
🏆 One-Liner Answer

"I wrote a single after update/delete trigger on Account routing to increment and decrement methods. Each method builds a Map of UserId→accountCount, queries current User Count__c, applies increment or decrement using Math.max(0,...) guard, and updates in one DML. 1 SOQL, 1 DML per operation, fully bulkified."


Advanced
Q
Add picklist values Electronic and Books to Product Family on Product2. Create Product Type picklist on Opportunity with same values. When an OLI is added, validate Product Family matches Opportunity Product Type — if not, throw an error.
Trigger + Helper
trigger OLIProductFamilyTrigger on OpportunityLineItem (before insert) {
    OLIProductFamilyHelper.validateProductFamily(Trigger.new);
}

public class OLIProductFamilyHelper {
    public static void validateProductFamily(List<OpportunityLineItem> newOLIs) {
        Set<Id> oppIds = new Set<Id>();
        Set<Id> productIds = new Set<Id>();
        for (OpportunityLineItem oli : newOLIs) {
            oppIds.add(oli.OpportunityId); productIds.add(oli.Product2Id);
        }

        Map<Id, Opportunity> oppMap = new Map<Id, Opportunity>([
            SELECT Id, Product_Type__c FROM Opportunity WHERE Id IN :oppIds]);
        Map<Id, Product2> productMap = new Map<Id, Product2>([
            SELECT Id, Family FROM Product2 WHERE Id IN :productIds]);

        for (OpportunityLineItem oli : newOLIs) {
            Opportunity opp = oppMap.get(oli.OpportunityId);
            Product2 prod   = productMap.get(oli.Product2Id);
            if (opp == null || String.isBlank(opp.Product_Type__c)) continue;
            if (prod == null || String.isBlank(prod.Family)) {
                oli.addError('Product Family not set on Product.'); continue;
            }
            if (opp.Product_Type__c != prod.Family)
                oli.addError('Product Family mismatch! Opp type is "'
                    + opp.Product_Type__c + '" but Product Family is "'
                    + prod.Family + '".');
        }
    }
}
🎯 Interview Questions
Why two separate SOQL queries (Opportunity + Product)?
Trigger.new only gives OLI fields. We need data from two different parent objects — Opportunity.Product_Type__c and Product2.Family. Two objects = two queries, cannot be combined.
What happens when multiple OLIs are added — some match, some don't?
addError() on individual records is selective — only the failing record is blocked. Valid OLIs still save. Per-record error handling allows partial success in bulk scenarios.
🏆 One-Liner Answer

"I wrote a before insert trigger on OLI that queries Opportunities and Products into separate Maps, then loops Trigger.new comparing opp.Product_Type__c vs prod.Family. Mismatch triggers addError() with descriptive message showing both values. If Opp has no Product Type, validation is skipped. 2 SOQL, 0 DML, fully bulkified."


Advanced
Q
Create Min Date and Max Date fields on Account and Opportunity. When Opportunities are inserted, updated, deleted, or undeleted — update the Account's Min and Max CloseDate.
Trigger + Helper (MIN + MAX via AggregateResult)
trigger OpportunityMinMaxDateTrigger on Opportunity
    (after insert, after update, after delete, after undelete) {
    List<Opportunity> oppList = Trigger.isDelete ? Trigger.old : Trigger.new;
    OpportunityMinMaxDateHelper.updateAccountMinMaxDate(oppList);
}

public class OpportunityMinMaxDateHelper {
    public static void updateAccountMinMaxDate(List<Opportunity> oppList) {
        Set<Id> accountIds = new Set<Id>();
        for (Opportunity opp : oppList) if (opp.AccountId != null) accountIds.add(opp.AccountId);
        if (accountIds.isEmpty()) return;

        Map<Id, Date> minMap = new Map<Id, Date>();
        Map<Id, Date> maxMap = new Map<Id, Date>();
        for (Id id : accountIds) { minMap.put(id, null); maxMap.put(id, null); }

        for (AggregateResult ar : [
            SELECT AccountId, MIN(CloseDate) minDate, MAX(CloseDate) maxDate
            FROM Opportunity
            WHERE AccountId IN :accountIds AND CloseDate != null
            GROUP BY AccountId]) {
            minMap.put((Id)ar.get('AccountId'), (Date)ar.get('minDate'));
            maxMap.put((Id)ar.get('AccountId'), (Date)ar.get('maxDate'));
        }

        List<Account> toUpdate = new List<Account>();
        for (Account acc : [SELECT Id, Min_Date__c, Max_Date__c
                FROM Account WHERE Id IN :accountIds]) {
            if (acc.Min_Date__c != minMap.get(acc.Id) || acc.Max_Date__c != maxMap.get(acc.Id)) {
                Account a = new Account(); a.Id = acc.Id;
                a.Min_Date__c = minMap.get(acc.Id); a.Max_Date__c = maxMap.get(acc.Id);
                toUpdate.add(a);
            }
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🏆 One-Liner Answer

"I wrote an after insert/update/delete/undelete trigger on Opportunity. One AggregateResult SOQL gets MIN and MAX CloseDate per Account grouped together. Initialize all Accounts with null so deleted-Opp accounts get both dates cleared. Only fire DML when either date changed. 2 SOQL, 1 DML, fully bulkified."


Advanced
Q
Write a trigger on Task — if all Tasks associated with an Opportunity have Status = Completed, update the Opportunity Stage to Closed Won.
📌 Key Point — WhatId is Polymorphic

Task.WhatId can link to Account, Opportunity, Case, or any object. Verify it's an Opportunity using the prefix '006'. Must query ALL Tasks per Opportunity — not just the updated ones.

Trigger + Helper
trigger TaskCompletedTrigger on Task (after update) {
    TaskCompletedHelper.closeOpportunityIfAllTasksDone(Trigger.new, Trigger.oldMap);
}

public class TaskCompletedHelper {
    public static void closeOpportunityIfAllTasksDone(List<Task> updatedTasks, Map<Id, Task> oldMap) {
        Set<Id> oppIds = new Set<Id>();
        for (Task tsk : updatedTasks) {
            Task old = oldMap.get(tsk.Id);
            if (tsk.Status == 'Completed' && old.Status != 'Completed'
                && tsk.WhatId != null
                && String.valueOf(tsk.WhatId).startsWith('006'))  // Opp prefix
                oppIds.add(tsk.WhatId);
        }
        if (oppIds.isEmpty()) return;

        // Query ALL Tasks per Opp — not just updated ones
        Map<Id, List<Task>> oppTaskMap = new Map<Id, List<Task>>();
        for (Task t : [SELECT Id, WhatId, Status FROM Task
                WHERE WhatId IN :oppIds]) {
            if (!oppTaskMap.containsKey(t.WhatId)) oppTaskMap.put(t.WhatId, new List<Task>());
            oppTaskMap.get(t.WhatId).add(t);
        }

        Set<Id> oppsToClose = new Set<Id>();
        for (Id oppId : oppTaskMap.keySet()) {
            Boolean allDone = true;
            for (Task t : oppTaskMap.get(oppId)) {
                if (t.Status != 'Completed') { allDone = false; break; }
            }
            if (allDone) oppsToClose.add(oppId);
        }
        if (oppsToClose.isEmpty()) return;

        List<Opportunity> toUpdate = new List<Opportunity>();
        for (Opportunity opp : [SELECT Id FROM Opportunity
                WHERE Id IN :oppsToClose AND StageName != 'Closed Won']) {
            Opportunity o = new Opportunity(); o.Id = opp.Id;
            o.StageName = 'Closed Won'; toUpdate.add(o);
        }
        if (!toUpdate.isEmpty()) update toUpdate;
    }
}
🎯 Interview Questions
Why check WhatId prefix '006'?
WhatId is polymorphic — it can point to Account, Case, or any object. Without the prefix check, we'd accidentally add Account or Case Ids into oppIds causing wrong SOQL results or errors.
Why query ALL Tasks per Opportunity?
Critical: if Opp has 3 Tasks and only 1 is updated, checking only updated Tasks would falsely assume all are done. Must query ALL remaining Tasks to verify every single one is Completed.
🏆 One-Liner Answer

"I wrote an after update trigger on Task using oldMap to detect Status changing TO Completed and WhatId prefix '006' for Opportunity validation. I query ALL Tasks per Opportunity, group by WhatId, check if all are Completed using early break optimization, then update qualifying Opportunity Stages to Closed Won. 2 SOQL, 1 DML, fully bulkified."


Intermediate
Q
Write a trigger on Account — prevent deletion if any child record exists (Contacts, Opportunities, Cases, or Assets).
Trigger + Helper (DRY with collectReasons)
trigger AccountChildDeleteTrigger on Account (before delete) {
    AccountChildDeleteHelper.preventDeletion(Trigger.old);
}

public class AccountChildDeleteHelper {
    public static void preventDeletion(List<Account> deletedAccounts) {
        Set<Id> accountIds = new Set<Id>();
        for (Account acc : deletedAccounts) accountIds.add(acc.Id);

        Map<Id, List<String>> blockMap = new Map<Id, List<String>>();

        collectReasons([SELECT AccountId, COUNT(Id) total FROM Contact
            WHERE AccountId IN :accountIds GROUP BY AccountId], blockMap, 'Contact');
        collectReasons([SELECT AccountId, COUNT(Id) total FROM Opportunity
            WHERE AccountId IN :accountIds GROUP BY AccountId], blockMap, 'Opportunity');
        collectReasons([SELECT AccountId, COUNT(Id) total FROM Case
            WHERE AccountId IN :accountIds GROUP BY AccountId], blockMap, 'Case');
        collectReasons([SELECT AccountId, COUNT(Id) total FROM Asset
            WHERE AccountId IN :accountIds GROUP BY AccountId], blockMap, 'Asset');

        for (Account acc : deletedAccounts) {
            if (blockMap.containsKey(acc.Id))
                acc.addError('Cannot delete — Child records exist: '
                    + String.join(blockMap.get(acc.Id), ' | ')
                    + '. Please remove all child records first.');
        }
    }
    private static void collectReasons(List<AggregateResult> results,
            Map<Id, List<String>> blockMap, String label) {
        for (AggregateResult ar : results) {
            Id accId = (Id)ar.get('AccountId');
            if ((Integer)ar.get('total') > 0) {
                if (!blockMap.containsKey(accId)) blockMap.put(accId, new List<String>());
                blockMap.get(accId).add(label + '(' + ar.get('total') + ')');
            }
        }
    }
}
🎯 Interview Questions
Why use AggregateResult COUNT() instead of querying records?
COUNT() returns one row per Account — not one per child record. If an Account has 10,000 Contacts, querying all of them wastes rows. COUNT() gives just the number we need with minimal data transfer.
Why the private collectReasons() helper method?
DRY principle — without it, the same 5-line pattern repeats 4 times. With the helper, adding a new child object is just one extra collectReasons() call. Makes code maintainable and readable.
🏆 One-Liner Answer

"I wrote a before delete trigger on Account using Trigger.old (Trigger.new is null in delete). 4 AggregateResult COUNT() SOQLs check Contacts, Opps, Cases, Assets — all routed through a reusable collectReasons() helper. addError() shows exactly which objects are blocking with counts like Contact(3)|Opportunity(2). 4 SOQL, 0 DML, fully bulkified."


Advanced
Q
Write a trigger on User — when a User is created or updated, if their profile is Platform User, add them to the 'Test_Group' Public Group.
📌 Key Object — GroupMember

GroupMember is a junction object linking Users to Public Groups. Fields: GroupId (the group), UserOrGroupId (the user). On insert: check if profile is Platform User. On update: check if profile specifically CHANGED TO Platform User using oldMap. Always check existing members to prevent duplicate DML errors.

Trigger + Helper
trigger UserProfileGroupTrigger on User (after insert, after update) {
    UserProfileGroupHelper.addToTestGroup(Trigger.new, Trigger.oldMap);
}

public class UserProfileGroupHelper {
    public static void addToTestGroup(List<User> users, Map<Id, User> oldMap) {
        List<Profile> profileList = [SELECT Id FROM Profile
            WHERE Name = 'Platform User' LIMIT 1];
        if (profileList.isEmpty()) return;
        Id platformProfileId = profileList[0].Id;

        Set<Id> eligibleUserIds = new Set<Id>();
        for (User usr : users) {
            if (oldMap == null) {  // INSERT
                if (usr.ProfileId == platformProfileId) eligibleUserIds.add(usr.Id);
            } else {  // UPDATE — only if profile CHANGED to Platform User
                if (usr.ProfileId == platformProfileId
                    && oldMap.get(usr.Id).ProfileId != platformProfileId)
                    eligibleUserIds.add(usr.Id);
            }
        }
        if (eligibleUserIds.isEmpty()) return;

        List<Group> groupList = [SELECT Id FROM Group
            WHERE DeveloperName = 'Test_Group' AND Type = 'Regular' LIMIT 1];
        if (groupList.isEmpty()) return;
        Id testGroupId = groupList[0].Id;

        Set<Id> alreadyInGroup = new Set<Id>();
        for (GroupMember gm : [SELECT UserOrGroupId FROM GroupMember
                WHERE GroupId = :testGroupId AND UserOrGroupId IN :eligibleUserIds])
            alreadyInGroup.add(gm.UserOrGroupId);

        List<GroupMember> toInsert = new List<GroupMember>();
        for (Id userId : eligibleUserIds) {
            if (alreadyInGroup.contains(userId)) continue;
            GroupMember gm = new GroupMember();
            gm.GroupId = testGroupId; gm.UserOrGroupId = userId;
            toInsert.add(gm);
        }
        if (!toInsert.isEmpty()) insert toInsert;
    }
}
🎯 Interview Questions
Why handle insert and update differently?
On insert, oldMap is null — just check current profile. On update, we use oldMap to detect if profile changed TO Platform User. Without this, any update to a Platform User (name, phone) would try to add them again — causing duplicate GroupMember error.
Why query Group by DeveloperName with Type=Regular?
DeveloperName is consistent across sandbox/production — Name can be renamed by admin. Type=Regular ensures we get the Public Group, not a Queue or Role group with same name.
Why check existing GroupMember before inserting?
Duplicate GroupMember (same GroupId + UserOrGroupId) throws a DML exception. Pre-checking and building an alreadyInGroup Set prevents this error cleanly.
🏆 One-Liner Answer

"I wrote an after insert/update trigger on User that queries Platform User profile Id, filters users whose profile matches (insert) or specifically changed to it (update via oldMap). Queries Test_Group by DeveloperName with Type=Regular, checks existing GroupMembers to prevent duplicates, then inserts GroupMember records. 3 SOQL, 1 DML, fully bulkified. Enhancement: handle profile changing AWAY from Platform User by deleting GroupMember."


⚙️ Conceptual Questions — Governor Limits, Debugging & Errors
Intermediate
Q
How many records can we insert, update, and delete using triggers? Is there any limitation?
📌 Key Concept — Multi-Tenant Governor Limits

Salesforce is a multi-tenant platform — all companies share the same infrastructure. Governor Limits prevent any single org from consuming all resources.

Trigger Batch Size

Triggers process records in batches of 200. If you insert 1000 records, the trigger fires 5 times (200×5). Each batch is a separate transaction with fresh governor limits.

Complete Governor Limits Reference
// ── DML Limits ────────────────────────────────────────
Trigger Batch Size              = 200 records per execution
Total DML Statements            = 150 per transaction
Total DML Rows                  = 10,000 per transaction

// ── SOQL Limits ───────────────────────────────────────
Total SOQL Queries (Sync)       = 100
Total SOQL Queries (Async)      = 200
Total SOQL Rows                 = 50,000
Batch Apex getQueryLocator      = 50,000,000

// ── CPU & Memory ──────────────────────────────────────
CPU Time (Sync)                 = 10,000 ms
CPU Time (Async)                = 60,000 ms
Heap Size (Sync)                = 6 MB
Heap Size (Async)               = 12 MB

// ── Email ─────────────────────────────────────────────
sendEmail() calls               = 10 per transaction

// ── Check usage programmatically ──────────────────────
System.debug(Limits.getDmlStatements());  // DML used
System.debug(Limits.getQueries());        // SOQL used
System.debug(Limits.getCpuTime());        // CPU used
🎯 Interview Questions
What is the difference between 150 DML statements vs 10,000 DML rows?
DML statements = HOW MANY times you call insert/update/delete. DML rows = HOW MANY total records across all calls. update listOf5000 = 1 DML statement but 5000 DML rows. Both limits apply independently.
How to handle large data beyond limits?
Future method (simple async), Queueable Apex (chaining, complex logic, 60,000ms CPU), Batch Apex (millions of records, 50M rows via getQueryLocator), Scheduled Apex (time-based). Always prefer Queueable over Future for new code.
🏆 One-Liner Answer

"Trigger batch size is 200 per execution. Key limits per transaction: 150 DML statements, 10,000 DML rows, 100 SOQL queries, 50,000 SOQL rows, 10,000ms CPU time. Most common violation is SOQL or DML inside a loop. Fix: collect in Sets/Maps/Lists, fire SOQL and DML once outside the loop. For large data beyond sync limits, use Queueable (200 SOQL, 60,000ms CPU) or Batch Apex. Monitor with Limits class."


Advanced
Q
If you get CPU timeout issues when working on your project — how can you resolve them?
📌 Error

Error Message

System.LimitException: Apex CPU time limit exceeded
Sync limit: 10,000ms  |  Async limit: 60,000ms
CPU Time = time executing code (SOQL wait time is EXCLUDED)

🔧 Causes & Fixes
Cause 1 — Nested Loops (Most Common)
Fix — Replace nested loop with Map
// ❌ O(n²) — 200×200 = 40,000 iterations
for (Account acc : accounts) {
    for (Contact con : contacts) { // CPU explodes!
        if (con.AccountId == acc.Id) { }
    }
}
// ✅ O(n) — build Map once, O(1) lookup
Map<Id, List<Contact>> accConMap = new Map<Id, List<Contact>>();
for (Contact con : contacts) {
    if (!accConMap.containsKey(con.AccountId))
        accConMap.put(con.AccountId, new List<Contact>());
    accConMap.get(con.AccountId).add(con);
}
for (Account acc : accounts) {
    List<Contact> cons = accConMap.get(acc.Id); // O(1) ✅
}
Cause 2 — String Concatenation in Loop
Fix — List + String.join()
// ❌ Creates new String object each iteration
String result = '';
for (Integer i = 0; i < 10000; i++) result += 'Item ' + i;

// ✅ Collect in List — join once
List<String> parts = new List<String>();
for (Integer i = 0; i < 10000; i++) parts.add('Item ' + i);
String result = String.join(parts, ', ');
Cause 3 — Synchronous Processing of Large Data
Move heavy processing to Queueable Apex — gets 60,000ms CPU limit instead of 10,000ms. In trigger: collect Ids, enqueue job. In Queueable: do the heavy work. Trigger stays fast and lean.
Cause 4 — Trigger Recursion
Use a static boolean flag: if (TriggerHelper.isExecuting) return; before logic. Set to true before DML, false after. Static variables persist within the same transaction — prevents the trigger from calling itself.
🏆 One-Liner Answer

"CPU timeout means Apex code consumed more than 10,000ms. Most common cause: nested loops — replacing them with Map lookups drops complexity from O(n²) to O(n) instantly. Other fixes: move SOQL outside loops, use String.join() instead of concatenation, static boolean recursion guard, and for large data — Queueable Apex with 60,000ms CPU limit. I use Limits.getCpuTime() during testing to find bottlenecks."


Intermediate
Q
How can you resolve the SOQL 101 Exception?

Error Message

System.LimitException: Too many SOQL queries: 101
Sync: 100 queries max  |  Async: 200 queries max

🔧 Causes & Fixes
Cause 1 — SOQL Inside Loop (Most Common)
Before vs After
// ❌ 200 Accounts = 200 SOQL = Exception on 101st
for (Account acc : Trigger.new) {
    List<Contact> cons = [SELECT Id FROM Contact
        WHERE AccountId = :acc.Id];
}
// ✅ 1 SOQL for ALL Accounts
Set<Id> accountIds = new Set<Id>();
for (Account acc : Trigger.new) accountIds.add(acc.Id);
Map<Id, List<Contact>> accConMap = new Map<Id, List<Contact>>();
for (Contact con : [SELECT Id, AccountId FROM Contact
        WHERE AccountId IN :accountIds]) {
    if (!accConMap.containsKey(con.AccountId))
        accConMap.put(con.AccountId, new List<Contact>());
    accConMap.get(con.AccountId).add(con);
}
Cause 2 — Multiple Triggers Sharing Limit
Account Trigger (30 SOQL) + Contact Trigger (40 SOQL) + Opp Trigger (35 SOQL) = 105 total. Fix: Use a static cache class — query once and reuse in the same transaction via a static Map.
Cause 3 — Flow + Trigger Combined
Flow Get Records elements count toward the same 100 SOQL limit. Reduce Get Records elements in Flow, use Collection Filters instead, or move complex logic from Flow to Apex for better control.
How to debug SOQL 101?
Setup → Debug Logs → Enable for your user → Perform the action → Open Debug Log → Filter by SOQL → Count queries and find which line fires inside a loop. Or use Developer Console + System.debug(Limits.getQueries()) at key points.
🏆 One-Liner Answer

"SOQL 101 means more than 100 queries fired in one transaction. Most common cause: SOQL inside a for loop. Fix: collect all Ids into a Set first, fire one SOQL using IN :setOfIds, build a Map for O(1) lookup. For helper methods called in loops, refactor them to accept a Set and return a Map. When Flow and Trigger run together, they share the same 100 limit — reduce Get Records in Flow. Use Queueable for async (200 SOQL limit). Monitor with Limits.getQueries()."


Intermediate
Q
What is a Null Pointer Exception? If you get one in your project — how can you resolve it?

Error Message

System.NullPointerException: Attempt to de-reference a null object
Happens when you try to access a method or property on a variable that is null.

🔧 Common Causes & Fixes
Cause 1 — SOQL Returns No Record (Direct Assignment)
Fix — Always use List + isEmpty()
// ❌ Direct SOQL to SObject — null if no record
Account acc = [SELECT Id FROM Account WHERE Name = 'XYZ' LIMIT 1];
acc.Name.length();  // NPE if no record found!

// ✅ Always use List → check isEmpty()
List<Account> accs = [SELECT Id, Name FROM Account
    WHERE Name = 'XYZ' LIMIT 1];
if (!accs.isEmpty()) Integer len = accs[0].Name.length();
Cause 2 — Relationship Field Not Queried
Fix — Include relationship in SOQL + null check
// ❌ Account not in query — con.Account is null
String name = con.Account.Name;  // NPE!
// ✅ Include in SOQL + check
List<Contact> cons = [SELECT Id, Account.Name FROM Contact...];
if (con.Account != null) String name = con.Account.Name;
Cause 3 — Calling String Method on Null
Best Fix — Safe Navigation Operator (?.) — Spring 21+
// ❌ Email could be null
con.Email.contains('@gmail.com');  // NPE!

// ✅ String.isNotBlank() check
if (String.isNotBlank(con.Email)) con.Email.contains('@gmail.com');

// ✅ Safe navigation operator — cleanest approach
Boolean isGmail = con.Email?.contains('@gmail.com');  // returns null if Email null
String  city    = con.Account?.BillingAddress?.city;    // chain safely
Cause 4 — Trigger.new in Delete Context
Trigger.new is always null in delete triggers. Always use Trigger.old in delete context — it holds the records being deleted.
Cause 5 — Arithmetic on Null Field
Use null-safe ternary: Decimal amount = opp.Amount != null ? opp.Amount : 0; before any arithmetic. Never do opp.Amount * 0.18 directly if Amount could be null.
🛠️ How to Debug NPE in Production
Step 1: Check Debug Log for error line number — Error: line 45, column 1 Step 2: Go to that line in Apex class Step 3: Identify which variable could be null: con.Account.Name → Account could be null Step 4: Add System.debug() before that line to confirm: System.debug('Account: ' + con.Account); Step 5: Add null check or ?. operator Step 6: Test in sandbox → deploy to production
🏆 One-Liner Answer

"Null Pointer Exception means code tried to access a property or call a method on a null variable. Common causes: single-record SOQL returning no results (fix: use List + isEmpty()), relationship field not in SOQL query, Map.get() returning null, Trigger.new in delete context (fix: use Trigger.old), and string methods on null fields. My go-to fix is the safe navigation operator ?. introduced in Spring 21 — opp.Account?.Client_Contact__r?.Email returns null safely instead of crashing. I use Debug Logs to find the exact line, then add System.debug() to identify the null variable."