Top 40 Salesforce Apex Triggers Interview Questions 2026 — Bulkification, Handler Pattern, Order of Execution & Real Code Examples
⚡ Apex Triggers
Salesforce Apex Triggers — Complete Interview Questions Guide
Bulkification, Governor Limits, Email, Sharing, Aggregates & Real Scenarios — 38 Questions Covered
Basic
Intermediate
Advanced
38 Questions
Questions Index
1Send Email on Contact Insert
2Prevent Duplicate Contact
3Only System Admin Deletes Task
4Copy Billing to Shipping Address
5Auto-Insert Default OLI on Opp
6Create Asset from OLI
7Create Duplicate Lead on Insert
8Close Lost Opps Older Than 30 Days
9Auto-Create Contact as Client Contact
10Send OLI Email to Client Contact
11Send Email to Admin on Account Insert
12Update Account Total Sales Quantities
13Send PDF Attachment to Lead
14Email Contacts When Account Type Changes
15Delete Opp When OLI Deleted
16Email Client Contact on Stage Change
17Auto-Create Quote on OLI Insert
18Asset Min Expiration Date on Account
19Total OLI Quantities on Account
20Total Opp Amount on Contact Update
21Copy OLI Products to Assets
22Sync BillingCity to MailingCity
23Sync Opp Client Contact to Account
24Serial No Auto-Increment on OLI
25Deduct Product Quantity from OLI
26Apex Sharing — Private OWD Student
27Create Opp + OLI from Asset Insert
28Account Summary Email to Manager
29Increment/Decrement User Count
30Validate OLI Product Family
31Account Min & Max Date from Opps
32Close Opp When All Tasks Done
33Prevent Account Delete — Any Child
34Platform User → Public Group
C1Governor Limits Explained
C2CPU Timeout Resolution
C3SOQL 101 Resolution
C4Null Pointer Exception Resolution
Q
Question 01 · 🟢 Basic
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.
✅ Answer
Use after insert (Contact needs an Id for
setTargetObjectId). Query template by DeveloperName, build SingleEmailMessage list, send 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, 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-Line Answer for Interview
"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."
Q
Question 02 · 🟠 Intermediate
Write a trigger on Contact to prevent duplicate records based on Contact Email and Contact Phone.
✅ Answer
Use before insert, before update to block before save. Collect Emails & Phones into Sets, fire one SOQL with
OR, use field-level addError() to block the specific duplicate field! 🚫📌 Key Concept
- ✅before insert, before update —
addError()in before context stops DML completely before save - ✅oldMap check on update — ensures a Contact never flags itself when Email/Phone unchanged
- ✅Field-level addError() —
con.Email.addError()highlights the specific field causing the issue — better UX
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.');
}
}
}
🎤 One-Line Answer for Interview
"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."
Q
Question 03 · 🟠 Intermediate
Write a trigger on Task — only System Administrator users should be able to delete a Task.
✅ Answer
Use before delete +
UserInfo.getProfileId() queried once outside the loop. If not System Administrator → addError() on every task in Trigger.old! 🔐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-Line Answer for Interview
"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."
Q
Question 04 · 🟢 Basic
Write a trigger on Account — when an Account is inserted, automatically populate the Shipping Address with the Billing Address.
✅ Answer
Use before insert — changes to
Trigger.new are auto-saved by Salesforce. Zero SOQL, Zero DML — most efficient approach possible! 🚀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;
}
}
}
}
🔑 Key Points for Interviewer
- 🔥In before insert, changes to Trigger.new are automatically saved — no separate DML needed
- 💡Can also be done with a Before Save Record-Triggered Flow — Salesforce recommends declarative for simple field updates
🎤 One-Line Answer for Interview
"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."
Q
Question 05 · 🟠 Intermediate
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.
✅ Answer
Must be after insert because OLI needs
OpportunityId. Get Standard Pricebook, query one active PricebookEntry, build OLIs with mandatory fields! 🛒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-Line Answer for Interview
"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."
Q
Question 06 · 🟠 Intermediate
Write a trigger on OpportunityLineItem — when a Line Item is created, create an Asset with the associated Account.
✅ Answer
Key chain: OLI → Opportunity → Account. OLI has no direct AccountId — must query Opportunity first. Mandatory Asset fields: Name, AccountId, Product2Id, Status! 🏗️
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;
}
}
🔑 Key Points for Interviewer
- 🔥OLI has no direct AccountId — chain is OLI → Opportunity → Account. Skipping causes NullPointerException
- 💡Use Map not List for Opportunities — O(1) lookup vs nested loops
- 💡1 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"I wrote an after insert trigger on OLI that collects Opportunity Ids, queries them into a Map with AccountId, then builds Asset records using AccountId from Opportunity, Product2Id, Quantity, UnitPrice, and Status=Purchased. All inserted in one DML. 1 SOQL, 1 DML, fully bulkified."
Q
Question 07 · 🟠 Intermediate
Write a trigger on Lead — when a Lead is inserted, create a duplicate Lead with the same details.
✅ Answer
Critical concern — Recursion Risk! Inserting the duplicate fires the trigger again → infinite loop. Must use a static boolean recursion guard! 🔄
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
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-Line Answer for Interview
"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."
Q
Question 08 · 🔴 Advanced
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.
✅ Answer
Use
Date.today().addDays(-30) and filter CreatedDate < thirtyDaysAgo directly in SOQL — not in Apex — for governor limit efficiency! 📅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 = today - 30. CreatedDate < that date means created BEFORE that point — 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-Line Answer for Interview
"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."
Q
Question 09 · 🔴 Advanced
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.
✅ Answer
Two DML Operations — DML 1: Insert Contact (to get Id). DML 2: Update Account with Contact Id. After DML 1, Salesforce populates Contact Ids on the same object references — no re-query needed! 🔗
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
}
}
🔑 Key Points for Interviewer
- 🔥Two DML calls are mandatory — Contact must exist before Account can reference it
- 💡Trigger.new records in after context are read-only — create fresh Account object with just Id + field
- 💡Zero SOQL, 2 DML, fully bulkified
🎤 One-Line Answer for Interview
"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."
Q
Question 10 · 🔴 Advanced
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.
✅ Answer
Data chain: OLI → Opportunity → Account → Client_Contact__r. Also need Product details via
PricebookEntry.Product2. Trigger.new doesn't carry relationship fields — must re-query OLIs! 📧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>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);
}
}
🔑 Key Points for Interviewer
- 🔥Re-query OLIs — Trigger.new only contains direct OLI fields, no relationship traversal
- 💡Safe navigation ?. —
oli.PricebookEntry?.Product2?.Namereturns null safely instead of NPE - 💡2 SOQL, 0 DML, fully bulkified
🎤 One-Line Answer for Interview
"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, 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."
Q
Question 11 · 🟢 Basic
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]".
✅ Answer
Query Admin user once outside the loop — their email doesn't change per Account. Production enhancement: store admin email in a Custom Label — eliminates SOQL entirely! 📧
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);
}
}
🎤 One-Line Answer for Interview
"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 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."
Q
Question 12 · 🔴 Advanced
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.
✅ Answer
Full Recalculation using AggregateResult — don't just add new quantity. Query ALL existing OLIs and sum from scratch. This is idempotent and handles retries/deletes correctly! 📊
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
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;
}
}
🔑 Key Points for Interviewer
- 🔥Full recalculation is idempotent — incremental addition can double-count on trigger retries or miss decrements on delete
- 🔥Why not native Roll-Up Summary? OLI → Opportunity → Account is two levels deep — native RUS only spans one direct Master-Detail
- 💡Initialize all Accounts with 0 before summing — ensures deleted-OLI accounts reset to 0
- 💡3 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"I wrote an after insert/update/delete trigger on OLI using AggregateResult SUM(Quantity) GROUP BY AccountId. 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."
Q
Question 13 · 🔴 Advanced
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.
✅ Answer
Build the EmailFileAttachment once outside the loop — one PDF for all leads. Query Document by
DeveloperName (consistent across environments). 1 SOQL, 0 DML! 📎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);
}
}
🔑 Key Points for Interviewer
- 💡Document (Classic) uses
BodyBlob. ContentVersion (modern Files) usesVersionDataBlob — rest is the same - 🔥Build attachment object outside the loop — one PDF object reused for all leads = zero extra processing
🎤 One-Line Answer for Interview
"I wrote an after insert trigger on Lead that queries the PDF from Documents by DeveloperName, 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."
Q
Question 14 · 🟠 Intermediate
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.
✅ Answer
Use oldMap comparison to detect Type change. Store Account Name in a Map during first loop — needed inside Contact loop where Account records aren't available! 📧
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);
}
}
🔑 Key Points for Interviewer
- 🔥oldMap comparison is critical — without it, emails go out for EVERY Account update (phone, name, etc.)
- 💡Store Account Name in Map during first loop — zero extra SOQL needed inside Contact loop
- 💡1 SOQL, 0 DML, fully bulkified
🎤 One-Line Answer for Interview
"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. All sent in one sendEmail() call. 1 SOQL, 0 DML, fully bulkified."
Q
Question 15 · 🟠 Intermediate
Write a trigger on OpportunityLineItem — when a Line Item is deleted, delete the parent Opportunity as well.
✅ Answer
Use before delete — if Opportunity deletion fails, OLI deletion also rolls back. Use
Trigger.old (Trigger.new is null in delete context)! 🗑️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()); }
}
}
}
🔑 Key Points for Interviewer
- 🔥Key business clarification: delete Opp only if no remaining OLIs exist? Query remaining OLIs excluding deleted ones — always clarify with interviewer
- 💡before delete preferred — if Opp deletion fails, OLI deletion rolls back in same transaction
- 💡1 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"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."
Q
Question 16 · 🔴 Advanced
Write a trigger on Opportunity — when the Stage changes, send an email to the Account's Client Contact. Subject: Account Update Info.
✅ Answer
Use oldMap to detect StageName change. Store Opportunity Name in a Map by AccountId. Query Accounts with
Client_Contact__r in one SOQL! 📧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);
}
}
🎤 One-Line Answer for Interview
"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."
Q
Question 17 · 🔴 Advanced
Write a trigger on OLI — when a Line Item is created, auto-create a Quote linked to the Opportunity.
✅ Answer
Check for existing Quotes first — makes trigger idempotent. Mandatory Quote fields: Name, OpportunityId, Pricebook2Id, Status. Pricebook must match Opportunity! 📋
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;
}
}
🔑 Key Points for Interviewer
- 🔥Idempotent check — query existing Quotes first, prevent re-creation on repeated trigger runs
- 🔥Pricebook2Id on Quote must match Opportunity — mismatch causes DML error
- 💡2 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"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 Pricebook2Id matching the Opp, ExpirationDate=CloseDate, Status=Draft, and same OwnerId. Idempotent duplicate check prevents re-creation. 2 SOQL, 1 DML."
Q
Question 18 · 🔴 Advanced
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.
✅ Answer
Use MIN(UsageEndDate) AggregateResult. Initialize all Accounts with null before populating — ensures accounts with all Assets deleted get field cleared! 📅
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); // Initialize with 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;
}
}
🔑 Key Points for Interviewer
- 🔥All 4 events covered — Insert may set new min, Update changes date, Delete may remove current min, Undelete may lower it again
- 🔥Null initialization critical — AggregateResult only returns rows where Assets exist. Without null init, deleted-all-assets account never gets field cleared
- 💡2 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"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."
Q
Question 19 · 🔴 Advanced
Create a field on Account (Total Quantity). Collect all OLI Quantities and populate the total on Account level. Handle insert, update, delete, and undelete.
✅ Answer
Same pattern as Q12 — extended to all 4 events. Use SUM(Quantity) GROUP BY Opportunity.AccountId. Initialize all Accounts with 0 before summing! 📊
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
// Key: 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-Line Answer for Interview
"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."
Q
Question 20 · 🔴 Advanced
Create a field on Account (Total Opportunity Amount). When a Contact is updated, collect all Opportunity Amounts and update the Account-level Total.
✅ Answer
Collect both old and new AccountId when AccountId changes — both accounts need recalculation. Use
SUM(Amount) GROUP BY AccountId AggregateResult! 💰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;
}
}
🔑 Key Points for Interviewer
- 🔥Collect old AccountId — if Contact moved A→B, Account A's total stays stale without recalculation
- 💡2 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"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."
Q
Question 21 · 🔴 Advanced
Write a trigger on Account — when an Account is updated, copy all Opportunity Line Items as Assets on that Account (same products, no duplicates).
✅ Answer
Duplicate Prevention — Unique Key Pattern. Build key =
AccountId + '_' + Product2Id. Also add new keys to Set inside the loop to prevent intra-batch duplicates! 🔑// 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-Line Answer for Interview
"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."
Q
Question 22 · 🟢 Basic
Write a trigger on Account — when BillingCity is updated, update all related Contacts' MailingCity with the new Account BillingCity.
✅ Answer
Use oldMap to detect BillingCity change. Compare
con.MailingCity != newCity before updating — prevents unnecessary DML and trigger cascades! 🏙️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;
}
}
🎤 One-Line Answer for Interview
"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."
Q
Question 23 · 🟠 Intermediate
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.
✅ Answer
Use oldMap to detect Client_Contact__c change. Store new Contact Id in Map keyed by AccountId. Only update Accounts where value differs! 🔗
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-Line Answer for Interview
"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."
Q
Question 24 · 🔴 Advanced
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.
✅ Answer
Store counter on parent Opportunity field (Last_OLI_Serial_No__c). Never use MAX() — it resets on delete. Counter only goes up, never down! 🔢
// 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 on OLI
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.
🎤 One-Line Answer for Interview
"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, sets Serial_No__c directly on Trigger.new — no OLI DML 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."
Q
Question 25 · 🔴 Advanced
Create Total Quantity and Available Quantity fields on Product2. When an OLI is inserted, updated, or deleted — deduct/adjust/restore the Product's Available Quantity accordingly.
✅ Answer
Three different scenarios — Insert: deduct full new qty. Update: deduct only difference (newQty - oldQty). Delete: restore full old qty. Use
System.TriggerOperation enum! ⚙️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);
}
}
🔑 Key Points for Interviewer
- 🔥Update deducts only DIFFERENCE — if insert already deducted 50, update to 70 should only deduct 20 more, not 70 again
- 💡Math.max(0, ...) prevents negative stock — production should throw error to block the insert
- 💡1 SOQL, 1 DML, fully bulkified
🎤 One-Line Answer for Interview
"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. Accumulated per Product in a Map using addToMap() helper. Math.max(0,...) prevents negative stock. 1 SOQL, 1 DML, fully bulkified."
Q
Question 26 · 🔴 Advanced
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.
✅ Answer
Apex Managed Sharing — OWD=Private auto-generates
Student__Share object. Insert Share records with RowCause=Manual. Use Database.insert(false) for partial success! 🔐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). 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 rolls back ALL successful shares.
Why skip the record Owner?
The record Owner already has Full Access automatically. Creating a Share record for them causes a duplicate error.
🎤 One-Line Answer for Interview
"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 AccessLevel=Read, RowCause=Manual — skipping the Owner. Database.insert(false) handles duplicate shares gracefully. 1 SOQL, 1 DML, fully bulkified."
Q
Question 27 · 🔴 Advanced
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.
✅ Answer
Two mandatory DML calls — DML 1: Insert Opportunity. DML 2: Insert OLI (needs Opp Id). After DML 1, Salesforce populates Opp Ids on same object references — no re-query! 🔗
// 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)
🔑 Key Points for Interviewer
- 🔥Two DML calls are mandatory — OLI requires OpportunityId which only exists after DML 1
- 💡After insert DML 1, Salesforce auto-populates Id on the same object reference in the Map — no re-query needed
🎤 One-Line Answer for Interview
"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), 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."
Q
Question 28 · 🔴 Advanced
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.
✅ Answer
Use AggregateResult COUNT(Id) GROUP BY AccountId for Contact counts. Group Accounts by Map<UserId, List<Account>> so each User gets their own email! 📊
// 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 + Account→Contact count table
// 3 SOQL, 0 DML, 1 sendEmail call
🔑 Key Points for Interviewer
- 🔥AggregateResult for Contact count — fetching all Contact records would waste thousands of SOQL rows
- 💡Group Accounts by Map<UserId, List<Account>> — multiple Users updated = each needs their own email
🎤 One-Line Answer for Interview
"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."
Q
Question 29 · 🟠 Intermediate
Create a Count field on User. When an Account is updated — increment Count by 1. When an Account is deleted — decrement Count by 1.
✅ Answer
One trigger per object — route to different methods using
Trigger.isUpdate and Trigger.isDelete. Use Math.max(0, ...) guard on decrement! 🔢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) {
applyToUsers(buildCountMap(accounts), true);
}
public static void decrementCount(List<Account> accounts) {
applyToUsers(buildCountMap(accounts), 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;
}
}
🔑 Key Points for Interviewer
- 🔥One trigger per object — prevents execution order conflicts, Salesforce best practice
- 💡Math.max(0, ...) on decrement prevents negative Count — Count already 0 + delete = stays 0
🎤 One-Line Answer for Interview
"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."
Q
Question 30 · 🔴 Advanced
Add picklist values Electronic and Books to Product Family. Create Product Type picklist on Opportunity. When an OLI is added, validate Product Family matches Opportunity Product Type — if not, throw an error.
✅ Answer
Query Opportunities and Products into separate Maps. Compare
opp.Product_Type__c vs prod.Family. Mismatch → addError() with descriptive message! ✅❌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 + '".');
}
}
}
🔑 Key Points for Interviewer
- 🔥Two separate SOQLs needed — Trigger.new only gives OLI fields. Need data from two different parent objects
- 💡Per-record addError() is selective — valid OLIs still save, only failing records are blocked
- 💡2 SOQL, 0 DML, fully bulkified
🎤 One-Line Answer for Interview
"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."
Q
Question 31 · 🔴 Advanced
Create Min Date and Max Date fields on Account. When Opportunities are inserted, updated, deleted, or undeleted — update the Account's Min and Max CloseDate.
✅ Answer
One AggregateResult gets both MIN and MAX CloseDate per Account. Initialize all to null before populating — ensures deleted-Opp accounts get both dates cleared! 📅
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-Line Answer for Interview
"I wrote an after insert/update/delete/undelete trigger on Opportunity. One AggregateResult SOQL gets MIN and MAX CloseDate per Account 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."
Q
Question 32 · 🔴 Advanced
Write a trigger on Task — if all Tasks associated with an Opportunity have Status = Completed, update the Opportunity Stage to Closed Won.
✅ Answer
WhatId is Polymorphic — verify it's an Opportunity using prefix
'006'. Must query ALL Tasks per Opportunity — not just the updated ones! 🎯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.
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-Line Answer for Interview
"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."
Q
Question 33 · 🟠 Intermediate
Write a trigger on Account — prevent deletion if any child record exists (Contacts, Opportunities, Cases, or Assets).
✅ Answer
Use AggregateResult COUNT() — not full record query. Route all 4 child checks through a reusable
collectReasons() helper method (DRY principle)! 🚫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') + ')');
}
}
}
}
🔑 Key Points for Interviewer
- 🔥COUNT() not full records — Account with 10,000 Contacts: COUNT() = 1 row, fetching all = 10,000 rows wasted
- 💡DRY principle — collectReasons() helper: adding new child object = just one extra call
- 💡4 SOQL, 0 DML, fully bulkified. Error shows:
Contact(3) | Opportunity(2)
🎤 One-Line Answer for Interview
"I wrote a before delete trigger on Account using Trigger.old. 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."
Q
Question 34 · 🔴 Advanced
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.
✅ Answer
GroupMember is the junction object. On insert: check current profile. On update: check if profile CHANGED TO Platform User via oldMap. Check existing members to prevent duplicates! 👥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-Line Answer for Interview
"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."
⚙️ Conceptual Questions — Governor Limits, Debugging & Errors
Q
Conceptual 01 · 🟠 Intermediate
How many records can we insert, update, and delete using triggers? Is there any limitation?
✅ Answer
Salesforce is a multi-tenant platform — Governor Limits prevent any single org from consuming all shared resources. Triggers process records in batches of 200! 📊
📋 Complete Governor Limits Reference
| Category | Limit | Sync / Async |
|---|---|---|
| Trigger Batch Size | 200 records per execution | Both |
| Total DML Statements | 150 | Sync |
| Total DML Rows | 10,000 | Both |
| Total SOQL Queries | 100 / 200 | Sync / Async |
| Total SOQL Rows | 50,000 | Both |
| Batch Apex getQueryLocator | 50,000,000 | Async |
| CPU Time | 10,000ms / 60,000ms | Sync / Async |
| Heap Size | 6MB / 12MB | Sync / Async |
| sendEmail() calls | 10 | Sync |
// Check usage programmatically
System.debug(Limits.getDmlStatements()); // DML used
System.debug(Limits.getQueries()); // SOQL used
System.debug(Limits.getCpuTime()); // CPU used ms
🔑 Key Points for Interviewer
- 🔥150 DML statements vs 10,000 DML rows — statements = HOW MANY times you call DML, rows = HOW MANY records across all calls
- 💡For large data: Queueable (200 SOQL, 60,000ms CPU), Batch Apex (millions of records), Scheduled Apex (time-based)
- 💡Prefer Queueable over Future for new code — supports chaining, complex logic, and better limits
🎤 One-Line Answer for Interview
"Trigger batch size is 200 per execution. Key limits: 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, fire SOQL and DML once outside the loop. For large data, use Queueable (60,000ms CPU) or Batch Apex. Monitor with Limits class."
Q
Conceptual 02 · 🔴 Advanced
If you get CPU timeout issues when working on your project — how can you resolve them?
✅ Answer
System.LimitException: Apex CPU time limit exceeded — Sync: 10,000ms, Async: 60,000ms. CPU Time = time executing code. SOQL wait time is EXCLUDED! ⏱️📋 Causes & Fixes
Cause 1 — Nested Loops (Most Common)
// ❌ O(n²) — 200×200 = 40,000 iterations — CPU explodes!
for (Account acc : accounts) {
for (Contact con : contacts) {
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
// ❌ Creates new String object each iteration — very slow!
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.🎤 One-Line Answer for Interview
"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."
Q
Conceptual 03 · 🟠 Intermediate
How can you resolve the SOQL 101 Exception?
✅ Answer
System.LimitException: Too many SOQL queries: 101 — Sync: 100 max, Async: 200 max. Most common cause: SOQL inside a for loop! 🔍📋 Causes & Fixes
Cause 1 — SOQL Inside Loop (Most Common)
// ❌ 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 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
System.debug(Limits.getQueries()) at key points.🎤 One-Line Answer for Interview
"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. 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()."
Q
Conceptual 04 · 🟠 Intermediate
What is a Null Pointer Exception? If you get one in your project — how can you resolve it?
✅ Answer
System.NullPointerException: Attempt to de-reference a null object — happens when you access a method or property on a variable that is null! 🚨📋 Common Causes & Fixes
Cause 1 — SOQL Returns No Record (Direct Assignment)
// ❌ Direct SOQL to SObject — null if no record found!
Account acc = [SELECT Id FROM Account WHERE Name = 'XYZ' LIMIT 1];
acc.Name.length(); // NPE!
// ✅ 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
// ❌ Account not in query — con.Account is null → NPE!
String name = con.Account.Name;
// ✅ Include in SOQL + null check
List<Contact> cons = [SELECT Id, Account.Name FROM Contact WHERE...];
if (con.Account != null) String name = con.Account.Name;
Cause 3 — Safe Navigation Operator ?. (Best Fix — Spring 21+)
// ❌ Email could be null
con.Email.contains('@gmail.com'); // NPE!
// ✅ Safe navigation operator — cleanest approach (Spring 21+)
Boolean isGmail = con.Email?.contains('@gmail.com'); // returns null safely
String city = con.Account?.BillingAddress?.city; // chain safely
// ✅ String.isNotBlank() for string checks
if (String.isNotBlank(con.Email)) con.Email.contains('@gmail.com');
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-Line Answer for Interview
"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). My go-to fix is the safe navigation operator ?. introduced in Spring 21 — opp.Account?.Client_Contact__r?.Email returns null safely. I use Debug Logs to find the exact line, then System.debug() to identify the null variable."
📚 Keep Preparing
New interview questions every week 🚀
Follow for fresh Salesforce Q&A, free courses, and real interview experiences — straight from the trenches.
👥 Follow on LinkedIn