Top 30 Salesforce Apex Trigger Coding Interview Questions 2026 — Write the Code
⚙️ Salesforce Interview Prep 2026
Top 30 Salesforce Apex Trigger
Write-the-Code Interview Questions
Write-the-Code Interview Questions
The exact trigger writing questions interviewers ask — complete working code, why it works, common mistakes, and XYZ Company real-world examples. Part 2 of the Apex Triggers series.
30Questions
6Sections
RealWorking Code
100%Free
💡 How to Use: Each question is phrased exactly as an interviewer would ask. Try writing the code yourself first — then check the answer. Focus on the PATTERN not just the code.
📋 Jump to Section
⚡ Section 1 — Basic Triggers
Q1–Q6 · Every Salesforce Developer Must Nail These
Q1
❓ Interviewer Asks
Write a trigger on Account that prevents duplicate Account names before insert and before update.
✅ Complete Working Code
// AccountTrigger.trigger
trigger AccountTrigger on Account (before insert, before update) {
AccountTriggerHandler.handleDuplicateNames(Trigger.new);
}
// AccountTriggerHandler.cls
public class AccountTriggerHandler {
public static void handleDuplicateNames(List<Account> newAccounts) {
Set<String> newNames = new Set<String>();
for (Account acc : newAccounts) {
if (acc.Name != null) newNames.add(acc.Name.toLowerCase());
}
Map<String, Account> existing = new Map<String, Account>();
for (Account acc : [SELECT Id, Name FROM Account WHERE Name IN :newNames]) {
existing.put(acc.Name.toLowerCase(), acc);
}
for (Account acc : newAccounts) {
if (acc.Name != null
&& existing.containsKey(acc.Name.toLowerCase())
&& existing.get(acc.Name.toLowerCase()).Id != acc.Id) {
acc.Name.addError('Account with this name already exists: ' + acc.Name);
}
}
}
}
💡 Why This Approach
Uses before insert/update to stop before hitting the database. Collects names in a Set first then ONE SOQL query. toLowerCase() for case-insensitive check. Id comparison prevents false positives on updates.
⚠️ Common Mistake
Writing SOQL inside the for loop — fails in bulk. Forgetting acc.Id != acc.Id check — breaks updates since the existing account IS the same record being updated!
🏢 XYZ Company Example
XYZ Company's sales team created duplicates like 'ABC Pharma' and 'Abc Pharma'. This trigger caught them case-insensitively — reducing duplicate cleanup time by 90%.
Q2
❓ Interviewer Asks
Write a before insert trigger on Contact to set the Description field to 'New Contact - [Today Date]' if it is blank.
✅ Complete Working Code
trigger ContactTrigger on Contact (before insert) {
for (Contact con : Trigger.new) {
if (String.isBlank(con.Description)) {
con.Description = 'New Contact - ' + Date.today().format();
}
}
}
💡 Why This Approach
Before insert modifies the record IN MEMORY — no extra DML needed. String.isBlank() handles both null AND empty string. Date.today().format() gives locale-friendly date.
⚠️ Common Mistake
Using after insert then doing an update DML — wastes a DML operation. Using == null instead of String.isBlank() — misses empty string cases.
🏢 XYZ Company Example
XYZ Company needed every new Contact timestamped. This before insert trigger auto-populated Description so the team always knew when a contact was first added.
Q3
❓ Interviewer Asks
Write a trigger to prevent deletion of Opportunities in 'Closed Won' stage.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before delete) {
for (Opportunity opp : Trigger.old) {
if (opp.StageName == 'Closed Won') {
opp.addError(
'Cannot delete a Closed Won Opportunity. ' +
'Please contact your administrator.'
);
}
}
}
💡 Why This Approach
On before delete, Trigger.old has the records being deleted — Trigger.new does NOT exist on delete. addError() stops deletion and shows the message in the UI. Clean and simple.
⚠️ Common Mistake
Using Trigger.new on delete — it doesn't exist! Delete triggers only have Trigger.old and Trigger.oldMap. Most common interview trap on delete triggers.
🏢 XYZ Company Example
XYZ Company's sales managers accidentally deleted Closed Won opps during cleanup. This trigger protected ₹2Cr+ in historical records.
Q4
❓ Interviewer Asks
Write an after insert trigger on Opportunity to create a follow-up Task assigned to the owner with due date 3 days from today.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (after insert) {
List<Task> tasksToInsert = new List<Task>();
for (Opportunity opp : Trigger.new) {
tasksToInsert.add(new Task(
Subject = 'Follow up: ' + opp.Name,
WhatId = opp.Id,
OwnerId = opp.OwnerId,
ActivityDate = Date.today().addDays(3),
Status = 'Not Started',
Priority = 'Normal'
));
}
if (!tasksToInsert.isEmpty()) insert tasksToInsert;
}
💡 Why This Approach
After insert required because we need the Opportunity ID — only available after insert. Build a List first then ONE bulk insert. isEmpty() check prevents unnecessary DML.
⚠️ Common Mistake
Doing insert task INSIDE the for loop — not bulkified, hits 150 DML limit with bulk loads. Always collect records in a List and insert outside the loop.
🏢 XYZ Company Example
XYZ Company's reps forgot to follow up on new leads. This trigger auto-created a Task the moment an Opportunity was created — no opportunity slipped through.
Q5
❓ Interviewer Asks
Write a trigger on Account to copy Billing Address to Shipping Address on insert, and only when Billing Address changes on update.
✅ Complete Working Code
trigger AccountTrigger on Account (before insert, before update) {
for (Account acc : Trigger.new) {
Boolean shouldCopy = false;
if (Trigger.isInsert) {
shouldCopy = true;
}
if (Trigger.isUpdate) {
Account old = Trigger.oldMap.get(acc.Id);
if (acc.BillingStreet != old.BillingStreet ||
acc.BillingCity != old.BillingCity ||
acc.BillingState != old.BillingState ||
acc.BillingPostalCode != old.BillingPostalCode ||
acc.BillingCountry != old.BillingCountry) {
shouldCopy = true;
}
}
if (shouldCopy) {
acc.ShippingStreet = acc.BillingStreet;
acc.ShippingCity = acc.BillingCity;
acc.ShippingState = acc.BillingState;
acc.ShippingPostalCode = acc.BillingPostalCode;
acc.ShippingCountry = acc.BillingCountry;
}
}
}
💡 Why This Approach
Uses Trigger.oldMap to compare old vs new — only copies when something actually changed. Checks all 5 address fields. Before trigger modifies in memory — no extra DML.
⚠️ Common Mistake
Not checking if billing address changed on update — overwrites manual shipping address every time ANY field on Account changes. Always compare old vs new.
🏢 XYZ Company Example
XYZ Company's Indian customers often have same billing and shipping address. This trigger saved the team from manually copying address on every new Account.
Q6
❓ Interviewer Asks
Write a trigger to validate that Phone on Contact is not blank and must contain at least 10 digits before insert and update.
✅ Complete Working Code
trigger ContactTrigger on Contact (before insert, before update) {
for (Contact con : Trigger.new) {
if (Trigger.isUpdate) {
Contact old = Trigger.oldMap.get(con.Id);
if (con.Phone == old.Phone) continue;
}
if (String.isBlank(con.Phone)) {
con.Phone.addError('Phone number is required.');
continue;
}
// Strip formatting, count only digits
String digits = con.Phone.replaceAll('[^0-9]', '');
if (digits.length() < 10) {
con.Phone.addError(
'Phone must have at least 10 digits. ' +
'Current: ' + digits.length() + ' digits.'
);
}
}
}
💡 Why This Approach
replaceAll('[^0-9]', '') strips +, -, spaces before counting digits — so '+91-9876543210' correctly counts as 12 digits. continue skips length check when phone is blank to avoid null pointer.
⚠️ Common Mistake
Using con.Phone.length() < 10 directly — counts formatting characters not digits. '+91-9876543210' has 16 characters but only 12 digits. Always strip non-numeric chars first.
🏢 XYZ Company Example
XYZ Company's reps entered partial numbers like '98765' to bypass required fields. This trigger enforced proper 10-digit minimum — improving contact data quality significantly.
🔥 Section 2 — Bulkification
Q7–Q12 · The #1 Topic That Separates Senior from Junior Candidates
Q7
❓ Interviewer Asks
This trigger has a critical bug — find it and rewrite it correctly.
trigger ContactTrigger on Contact (after insert) {
for (Contact con : Trigger.new) {
Account acc = [SELECT Id FROM Account WHERE Id = :con.AccountId];
acc.Description = 'Has contacts';
update acc;
}
}✅ Complete Working Code
// FIXED — Bulkified version
trigger ContactTrigger on Contact (after insert) {
// Step 1: Collect Account IDs
Set<Id> accountIds = new Set<Id>();
for (Contact con : Trigger.new) {
if (con.AccountId != null) accountIds.add(con.AccountId);
}
if (accountIds.isEmpty()) return;
// Step 2: ONE query outside loop
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id FROM Account WHERE Id IN :accountIds]
);
// Step 3: Build update list
List<Account> toUpdate = new List<Account>();
for (Account acc : accountMap.values()) {
acc.Description = 'Has contacts';
toUpdate.add(acc);
}
// Step 4: ONE DML outside loop
if (!toUpdate.isEmpty()) update toUpdate;
}
💡 Why This Approach
Original had SOQL and DML inside the for loop — both governor limit violations. Fixed version uses Set for dedup, one Map query, one bulk DML. Null check on AccountId prevents NullPointerException.
⚠️ Common Mistake
The original bugs: SOQL in loop (max 100 SOQL), DML in loop (max 150 DML), no null check on AccountId. With 200 contacts — original throws LimitException immediately.
🏢 XYZ Company Example
During a 5,000 Contact data migration at XYZ Company — the non-bulkified trigger would have failed at record 100. Fixed version handled all 5,000 in a single transaction.
Q8
❓ Interviewer Asks
Write a bulkified trigger to maintain a Contact count on Account. Update Contact_Count__c when Contacts are inserted or deleted.
✅ Complete Working Code
trigger ContactTrigger on Contact (after insert, after delete, after undelete) {
Set<Id> accountIds = new Set<Id>();
if (Trigger.new != null) {
for (Contact con : Trigger.new) {
if (con.AccountId != null) accountIds.add(con.AccountId);
}
}
if (Trigger.old != null) {
for (Contact con : Trigger.old) {
if (con.AccountId != null) accountIds.add(con.AccountId);
}
}
if (accountIds.isEmpty()) return;
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id FROM Account WHERE Id IN :accountIds]
);
// Fresh count via AggregateResult — always accurate
Map<Id, Integer> contactCounts = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT AccountId, COUNT(Id) cnt
FROM Contact WHERE AccountId IN :accountIds
GROUP BY AccountId
]) {
contactCounts.put((Id)ar.get('AccountId'), (Integer)ar.get('cnt'));
}
List<Account> toUpdate = new List<Account>();
for (Id accId : accountIds) {
Account acc = accountMap.get(accId);
if (acc != null) {
acc.Contact_Count__c = contactCounts.containsKey(accId)
? contactCounts.get(accId) : 0;
toUpdate.add(acc);
}
}
if (!toUpdate.isEmpty()) update toUpdate;
}
💡 Why This Approach
AggregateResult with COUNT(Id) GROUP BY gets the accurate fresh count in one query. Handles after undelete too — often missed. Sets count to 0 when no contacts remain via containsKey check.
⚠️ Common Mistake
Manually incrementing/decrementing count using Trigger.new/old — breaks with bulk operations and concurrent updates. Always re-query the actual count from database for accuracy.
🏢 XYZ Company Example
XYZ Company used Contact_Count__c on Account list views — enabling 'Accounts with 0 contacts' filters for territory management and data quality checks.
Q9
❓ Interviewer Asks
Write a bulkified trigger that updates all related Contacts' Department field when the Account's Industry field changes.
✅ Complete Working Code
trigger AccountTrigger on Account (after update) {
// Only process accounts where Industry actually changed
Map<Id, String> changedAccounts = new Map<Id, String>();
for (Account acc : Trigger.new) {
Account old = Trigger.oldMap.get(acc.Id);
if (acc.Industry != old.Industry && acc.Industry != null) {
changedAccounts.put(acc.Id, acc.Industry);
}
}
if (changedAccounts.isEmpty()) return;
List<Contact> contactsToUpdate = [
SELECT Id, AccountId
FROM Contact
WHERE AccountId IN :changedAccounts.keySet()
];
for (Contact con : contactsToUpdate) {
con.Department = changedAccounts.get(con.AccountId);
}
if (!contactsToUpdate.isEmpty()) update contactsToUpdate;
}
💡 Why This Approach
Only processes accounts where Industry ACTUALLY changed using Trigger.oldMap comparison. Exits early if nothing changed. Map stores accountId→newIndustry so contact loop can efficiently look up new value.
⚠️ Common Mistake
Not checking if the field changed — updates ALL contacts every time ANY field on Account changes. Causes massive unnecessary DML operations on every Account save.
🏢 XYZ Company Example
When XYZ Company reclassified an Account from Manufacturing to Pharmaceutical, all 500+ related Contacts' Department updated automatically in one transaction.
Q10
❓ Interviewer Asks
Write a trigger to prevent more than 5 open Opportunities per Account. Block the 6th one with an error.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before insert) {
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null && !opp.IsClosed) {
accountIds.add(opp.AccountId);
}
}
if (accountIds.isEmpty()) return;
// Count existing open opps
Map<Id, Integer> existingCounts = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT AccountId, COUNT(Id) cnt FROM Opportunity
WHERE AccountId IN :accountIds AND IsClosed = false
GROUP BY AccountId
]) {
existingCounts.put((Id)ar.get('AccountId'), (Integer)ar.get('cnt'));
}
// Count NEW opps per account in this same transaction
Map<Id, Integer> newCounts = new Map<Id, Integer>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null && !opp.IsClosed) {
Integer c = newCounts.containsKey(opp.AccountId)
? newCounts.get(opp.AccountId) : 0;
newCounts.put(opp.AccountId, c + 1);
}
}
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null && !opp.IsClosed) {
Integer existing = existingCounts.containsKey(opp.AccountId)
? existingCounts.get(opp.AccountId) : 0;
Integer incoming = newCounts.containsKey(opp.AccountId)
? newCounts.get(opp.AccountId) : 0;
if ((existing + incoming) > 5) {
opp.addError('Max 5 open Opportunities allowed per Account.');
}
}
}
}
💡 Why This Approach
Counts BOTH existing open opps (from SOQL) AND incoming new opps (Trigger.new). Handles the edge case: Account has 4 opps, insert 3 at once — each individual check sees 4+1=5 (OK) but real total is 4+3=7.
⚠️ Common Mistake
Only checking existing opps without counting multiple new opps in same transaction — most common mistake. Shows lack of bulk thinking and is a senior developer filter question.
🏢 XYZ Company Example
XYZ Company's pipeline got cluttered with 20+ open opps per account. This trigger enforced pipeline discipline — max 5 open at any time per account.
Q11
❓ Interviewer Asks
Explain Set vs Map in triggers and write a trigger using both — update Account's Last_Activity_Date__c when any related Opportunity is updated.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (after update) {
// SET — unique Account IDs, no duplicates
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) accountIds.add(opp.AccountId);
}
if (accountIds.isEmpty()) return;
// MAP — accountId to Account for O(1) lookup (vs O(n) List search)
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id FROM Account WHERE Id IN :accountIds]
);
// MAP — accountId to most recent date
Map<Id, Date> latestDates = new Map<Id, Date>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) {
Date d = opp.LastModifiedDate.date();
if (!latestDates.containsKey(opp.AccountId) ||
latestDates.get(opp.AccountId) < d) {
latestDates.put(opp.AccountId, d);
}
}
}
List<Account> toUpdate = new List<Account>();
for (Id accId : accountIds) {
Account acc = accountMap.get(accId);
if (acc != null) {
acc.Last_Activity_Date__c = latestDates.get(accId);
toUpdate.add(acc);
}
}
if (!toUpdate.isEmpty()) update toUpdate;
}
💡 Why This Approach
Set = uniqueness only, no value lookup. Map = key→value lookup at O(1) speed. Using Map vs List for account lookup: Map is O(1) vs O(n) per lookup. With 5,000 records, List = 25 million iterations. Map = 5,000.
⚠️ Common Mistake
Using a List for Account records and iterating to find the right account — O(n²) complexity. Always use Maps for lookup operations. This is the foundation of every senior Apex developer's toolkit.
🏢 XYZ Company Example
XYZ Company's management dashboard needed 'Last Opportunity Activity' on Account. This trigger kept it always in sync — identifying dormant accounts with no activity in 90+ days.
Q12
❓ Interviewer Asks
Write a trigger to update a custom field Account_Name__c on Opportunity with the related Account name. Why can't you use opp.Account.Name directly?
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before insert, before update) {
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) accountIds.add(opp.AccountId);
}
if (accountIds.isEmpty()) return;
// Must explicitly query — cross-object fields NOT in Trigger.new
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
opp.Account_Name__c = accountMap.get(opp.AccountId).Name;
}
}
}
💡 Why This Approach
opp.Account.Name returns NULL in triggers unless you explicitly query it. Trigger.new only contains fields on the Opportunity — not related object fields. Must query the parent separately.
⚠️ Common Mistake
Trying opp.Account.Name directly — always returns null in a trigger. Cross-object relationships are NOT populated in Trigger.new automatically. Very common interview gotcha question.
🏢 XYZ Company Example
XYZ Company's custom reports needed Account Name denormalized onto Opportunity for Business Central export. Trigger kept Account_Name__c always in sync — no manual entry.
✅ Section 3 — Validation & Business Rules
Q13–Q18 · Real Business Scenarios Interviewers Love
Q13
❓ Interviewer Asks
Write a trigger to prevent Opportunity Close Date from being set to a past date on insert or update.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before insert, before update) {
Date today = Date.today();
for (Opportunity opp : Trigger.new) {
if (opp.CloseDate != null && opp.CloseDate < today) {
// Allow past dates on already-closed opps
if (Trigger.isInsert ||
(Trigger.isUpdate && !Trigger.oldMap.get(opp.Id).IsClosed)) {
opp.CloseDate.addError(
'Close Date cannot be in the past. ' +
'Please enter today or a future date.'
);
}
}
}
}
💡 Why This Approach
The nuance: allows past close dates when updating already-closed opportunities — don't force re-dating historical records. Checks !IsClosed on old record to determine if it was previously open.
⚠️ Common Mistake
Blocking ALL past dates including already-closed opportunities — breaks legitimate updates to historical records. Always consider the full business context when writing validation triggers.
🏢 XYZ Company Example
XYZ Company's reps set Close Dates as yesterday just to close overdue opportunities. This trigger prevented gaming pipeline reports — forcing honest close date forecasting.
Q14
❓ Interviewer Asks
Write a trigger to restrict Account deletion to System Administrator profile users only.
✅ Complete Working Code
trigger AccountTrigger on Account (before delete) {
// Query outside loop — profile same for all records in one transaction
String profileName = [
SELECT Profile.Name FROM User
WHERE Id = :UserInfo.getUserId()
].Profile.Name;
if (profileName != 'System Administrator') {
for (Account acc : Trigger.old) {
acc.addError(
'You do not have permission to delete Account records. ' +
'Please contact your System Administrator.'
);
}
}
}
💡 Why This Approach
Queries running user's profile using UserInfo.getUserId() — the correct approach. SOQL is outside the loop (profile is same for all records). Compares Profile.Name string — not ID.
⚠️ Common Mistake
Using UserInfo.getProfileId() and comparing Profile IDs — ID-based comparison breaks in sandboxes vs production (different Profile IDs in each org). Always compare Profile Names, never IDs.
🏢 XYZ Company Example
XYZ Company's sales reps accidentally deleted key Account records during cleanup. This trigger ensured only System Admins could delete Accounts — protecting critical customer data.
Q15
❓ Interviewer Asks
Write a trigger to make Opportunity read-only — prevent any field updates once Stage is 'Closed Won'.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before update) {
String profileName = [
SELECT Profile.Name FROM User
WHERE Id = :UserInfo.getUserId()
].Profile.Name;
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
// Check PREVIOUS state — if it WAS Closed Won, block all changes
if (oldOpp.StageName == 'Closed Won') {
if (profileName != 'System Administrator') {
opp.addError(
'Closed Won Opportunities are locked. ' +
'Contact your administrator for changes.'
);
}
}
}
}
💡 Why This Approach
Checks oldOpp.StageName == 'Closed Won' — the PREVIOUS state. If it was Closed Won before this update, block it. Prevents all edits including Stage changes away from Closed Won. Admin bypass included.
⚠️ Common Mistake
Checking opp.StageName (new value) instead of oldOpp.StageName — blocks updates while IN Closed Won but misses the case where someone is changing FROM Closed Won. Must check OLD value.
🏢 XYZ Company Example
XYZ Company's reps edited Closed Won amounts retrospectively to manipulate commission reports. This trigger locked all fields on Closed Won opps — protecting revenue integrity.
Q16
❓ Interviewer Asks
Write a trigger to create an audit log when Opportunity Amount changes. Log to Opportunity_Audit__c with Old_Amount__c, New_Amount__c, Changed_By__c, Changed_Date__c.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (after update) {
List<Opportunity_Audit__c> auditLogs = new List<Opportunity_Audit__c>();
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
// Only log if Amount ACTUALLY changed
if (opp.Amount != oldOpp.Amount) {
auditLogs.add(new Opportunity_Audit__c(
Opportunity__c = opp.Id,
Old_Amount__c = oldOpp.Amount,
New_Amount__c = opp.Amount,
Changed_By__c = UserInfo.getUserId(),
Changed_Date__c = DateTime.now()
));
}
}
if (!auditLogs.isEmpty()) insert auditLogs;
}
💡 Why This Approach
Uses after update — creating records (DML) not allowed on other objects in before context. Compares opp.Amount != oldOpp.Amount — only logs when amount actually changed. UserInfo.getUserId() captures the changing user.
⚠️ Common Mistake
Creating audit records on EVERY update regardless of whether Amount changed — creates massive audit data for completely unrelated field changes. Always compare old vs new before creating audit records.
🏢 XYZ Company Example
XYZ Company's finance team needed a full audit trail of Amount changes for SOX compliance. This trigger provided complete change history — who changed what amount and when.
Q17
❓ Interviewer Asks
Write a trigger to enforce that an Opportunity CANNOT jump from 'Proposal Sent' directly to 'Closed Won' — must pass through 'Negotiation' first.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before update) {
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
// Check exact invalid transition
if (oldOpp.StageName == 'Proposal Sent' &&
opp.StageName == 'Closed Won') {
opp.StageName.addError(
'Invalid stage transition. ' +
'Opportunity must pass through Negotiation ' +
'before being marked Closed Won.'
);
}
}
}
💡 Why This Approach
Checks the exact old→new stage transition using Trigger.oldMap. Error on the specific field (opp.StageName.addError) so it appears next to the field in the UI — better UX than a record-level error.
⚠️ Common Mistake
Only checking new value (opp.StageName == 'Closed Won') without checking old value — blocks ALL moves to Closed Won, not just the specific invalid transition. Always check BOTH old and new for stage validation.
🏢 XYZ Company Example
XYZ Company's sales process required Negotiation stage for all deals above ₹1L. Reps were skipping it to game win rates. This trigger enforced the proper sales process automatically.
Q18
❓ Interviewer Asks
Write a trigger to prevent Opportunity deletion if it has related Quote records.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (before delete) {
Set<Id> oppIds = new Set<Id>();
for (Opportunity opp : Trigger.old) oppIds.add(opp.Id);
// Count related Quotes using AggregateResult
Map<Id, Integer> quoteCounts = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT OpportunityId, COUNT(Id) cnt
FROM Quote
WHERE OpportunityId IN :oppIds
GROUP BY OpportunityId
]) {
quoteCounts.put((Id)ar.get('OpportunityId'), (Integer)ar.get('cnt'));
}
for (Opportunity opp : Trigger.old) {
if (quoteCounts.containsKey(opp.Id) && quoteCounts.get(opp.Id) > 0) {
opp.addError(
'Cannot delete Opportunity with ' +
quoteCounts.get(opp.Id) +
' related Quote(s). Delete the Quotes first.'
);
}
}
}
💡 Why This Approach
AggregateResult with GROUP BY counts related Quotes for all Opportunities in one query. Error message is specific about the count and what action to take — better user guidance.
⚠️ Common Mistake
Running SELECT COUNT() FROM Quote WHERE OpportunityId = :opp.Id inside the for loop — SOQL in loop, fails on bulk delete. Always use AggregateResult with GROUP BY for counting related records.
🏢 XYZ Company Example
XYZ Company's reps deleted Opportunities without realizing they had sent PDF Quotes to customers. This trigger prevented deletion — protecting the quote audit trail.
🏗️ Section 4 — Handler Pattern & Best Practices
Q19–Q22 · Senior Developer Questions — Architecture Matters
Q19
❓ Interviewer Asks
What is the Trigger Handler pattern? Write an example with one trigger on Account and a Handler class.
✅ Complete Working Code
// AccountTrigger.trigger — THIN, routing only, zero logic
trigger AccountTrigger on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
AccountTriggerHandler handler = new AccountTriggerHandler();
if (Trigger.isBefore) {
if (Trigger.isInsert) handler.onBeforeInsert(Trigger.new);
if (Trigger.isUpdate) handler.onBeforeUpdate(Trigger.new, Trigger.oldMap);
if (Trigger.isDelete) handler.onBeforeDelete(Trigger.old);
}
if (Trigger.isAfter) {
if (Trigger.isInsert) handler.onAfterInsert(Trigger.new);
if (Trigger.isUpdate) handler.onAfterUpdate(Trigger.new, Trigger.oldMap);
if (Trigger.isDelete) handler.onAfterDelete(Trigger.old);
if (Trigger.isUndelete) handler.onAfterUndelete(Trigger.new);
}
}
// AccountTriggerHandler.cls — ALL logic lives here
public class AccountTriggerHandler {
public void onBeforeInsert(List<Account> newAccounts) { /* logic */ }
public void onBeforeUpdate(List<Account> n, Map<Id,Account> old) { /* logic */ }
public void onAfterInsert(List<Account> newAccounts) { /* logic */ }
public void onAfterUpdate(List<Account> n, Map<Id,Account> old) { /* logic */ }
public void onBeforeDelete(List<Account> oldAccounts) { /* logic */ }
public void onAfterDelete(List<Account> oldAccounts) { }
public void onAfterUndelete(List<Account> newAccounts) { }
}
💡 Why This Approach
One trigger per object — avoids execution order issues. Handler class is testable independently. Trigger = routing only. Handler = all logic. Easy to add new business rules without touching the trigger file.
⚠️ Common Mistake
Writing multiple triggers on the same object — Salesforce does NOT guarantee execution order of multiple triggers. Always ONE trigger per object with a handler. This is a fundamental senior developer best practice.
🏢 XYZ Company Example
XYZ Company had 6 Account triggers written by different developers over 3 years — running in unpredictable order. Refactoring to one trigger + handler eliminated 3 production bugs caused by trigger order conflicts.
Q20
❓ Interviewer Asks
Write a trigger with a static boolean flag to prevent recursive execution. Why is this needed? What happens without it?
✅ Complete Working Code
// TriggerHelper.cls — Static recursion guard
public class TriggerHelper {
public static Boolean isRunning = false;
}
// AccountTrigger.trigger
trigger AccountTrigger on Account (after update) {
if (TriggerHelper.isRunning) return; // Already running — exit
TriggerHelper.isRunning = true;
try {
List<Account> toUpdate = new List<Account>();
for (Account acc : Trigger.new) {
toUpdate.add(new Account(
Id = acc.Id,
Description = 'Updated: ' + DateTime.now()
));
}
if (!toUpdate.isEmpty()) {
update toUpdate; // Would normally re-fire trigger!
}
} finally {
// ALWAYS reset flag — even on exception
TriggerHelper.isRunning = false;
}
}
💡 Why This Approach
Static variables persist for the entire transaction. Without the flag: trigger updates Account → re-fires trigger → updates again → infinitely → 'Maximum trigger depth exceeded'. try/finally ensures flag is ALWAYS reset even on exceptions.
⚠️ Common Mistake
Not using try/finally to reset the flag — if an exception occurs before the reset, the flag stays true and ALL subsequent trigger executions in the transaction are silently skipped. Very hard to diagnose production bug.
🏢 XYZ Company Example
XYZ Company's Visit Report trigger updated a parent record which re-fired the trigger — 'Maximum trigger depth exceeded' error at 2 AM in production. Static flag fixed it in 5 minutes.
Q21
❓ Interviewer Asks
Write a trigger using Custom Metadata Types — max open opportunities per Account should be configurable without code deployment.
✅ Complete Working Code
// Custom Metadata: Opportunity_Rules__mdt
// DeveloperName: Default_Rules | Max_Open_Opps__c = 5
trigger OpportunityTrigger on Opportunity (before insert) {
// No hardcoded values — read from Custom Metadata
Opportunity_Rules__mdt config = [
SELECT Max_Open_Opps__c FROM Opportunity_Rules__mdt
WHERE DeveloperName = 'Default_Rules' LIMIT 1
];
Integer maxOpps = (Integer)config.Max_Open_Opps__c;
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null && !opp.IsClosed)
accountIds.add(opp.AccountId);
}
if (accountIds.isEmpty()) return;
Map<Id, Integer> openCounts = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT AccountId, COUNT(Id) cnt FROM Opportunity
WHERE AccountId IN :accountIds AND IsClosed = false
GROUP BY AccountId
]) {
openCounts.put((Id)ar.get('AccountId'), (Integer)ar.get('cnt'));
}
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null && !opp.IsClosed) {
Integer c = openCounts.containsKey(opp.AccountId)
? openCounts.get(opp.AccountId) : 0;
if (c >= maxOpps) {
opp.addError('Max ' + maxOpps + ' open Opportunities per Account.');
}
}
}
}
💡 Why This Approach
Custom Metadata allows admins to change business rules WITHOUT code deployment. Change from 5 to 10 → update Custom Metadata record → done. Custom Metadata queries don't count against SOQL limits — cached per transaction.
⚠️ Common Mistake
Hardcoding business rule values in triggers — every threshold change requires developer code change + Change Set + deployment. Custom Metadata moves configuration to admins where it belongs.
🏢 XYZ Company Example
XYZ Company's management changed pipeline rules quarterly. Before Custom Metadata — each change needed code + deployment. After — management updated Custom Metadata themselves. Zero developer time. 🎉
Q22
❓ Interviewer Asks
Write a trigger using Database.insert(list, false) for partial success. What is the difference from a regular insert statement?
✅ Complete Working Code
trigger AccountTrigger on Account (after insert) {
List<Task> tasksToInsert = new List<Task>();
for (Account acc : Trigger.new) {
tasksToInsert.add(new Task(
Subject = 'Welcome: ' + acc.Name,
WhatId = acc.Id,
OwnerId = acc.OwnerId,
ActivityDate = Date.today().addDays(7),
Status = 'Not Started'
));
}
if (!tasksToInsert.isEmpty()) {
// allOrNone = false allows partial success
Database.SaveResult[] results = Database.insert(tasksToInsert, false);
List<Error_Log__c> errorLogs = new List<Error_Log__c>();
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
for (Database.Error err : results[i].getErrors()) {
errorLogs.add(new Error_Log__c(
Error_Message__c = err.getMessage(),
Error_Fields__c = String.join(err.getFields(), ', '),
Error_Time__c = DateTime.now()
));
}
}
}
if (!errorLogs.isEmpty()) {
try { insert errorLogs; }
catch (DmlException e) { System.debug('Error log failed: ' + e.getMessage()); }
}
}
}
💡 Why This Approach
Database.insert(list, false) = partial success — only failed records rollback. Database.SaveResult[] lets you check which records succeeded/failed. Enterprise-grade error handling — errors logged for investigation without blocking successful records.
⚠️ Common Mistake
Using insert list (all-or-none by default) — if ONE Task fails, ALL 999 Accounts fail to get their welcome task. Database.insert with false allows 999 to succeed even if 1 fails.
🏢 XYZ Company Example
XYZ Company's welcome task trigger failed the entire batch when one Account had a missing OwnerId. Switching to Database.insert(list, false) meant 999 Accounts got tasks — and errors were logged for investigation.
🚀 Section 5 — Advanced Triggers
Q23–Q27 · Senior and Lead Developer Level
Q23
❓ Interviewer Asks
Write a trigger that calls an external REST API when a new Account is created. Why can't you call it directly in the trigger? What is the correct approach?
✅ Complete Working Code
// AccountTrigger.trigger
trigger AccountTrigger on Account (after insert) {
Set<Id> accountIds = new Set<Id>();
for (Account acc : Trigger.new) accountIds.add(acc.Id);
// Cannot make HTTP callout during open transaction — use @future
AccountCalloutService.notifyExternalSystem(accountIds);
}
// AccountCalloutService.cls
public class AccountCalloutService {
@future(callout=true)
public static void notifyExternalSystem(Set<Id> accountIds) {
List<Account> accounts = [
SELECT Id, Name, Phone FROM Account WHERE Id IN :accountIds
];
for (Account acc : accounts) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_System/api/accounts');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(new Map<String,Object>{
'salesforceId' => acc.Id,
'name' => acc.Name,
'phone' => acc.Phone
}));
req.setTimeout(30000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
System.debug('Callout failed: ' + res.getBody());
}
} catch (CalloutException e) {
System.debug('Callout error: ' + e.getMessage());
}
}
}
}
💡 Why This Approach
Salesforce does NOT allow HTTP callouts during open transactions — database hasn't committed yet. @future(callout=true) runs AFTER the transaction commits — Account is saved first, then the external call happens. If callout fails, the Account record is already safe.
⚠️ Common Mistake
Trying Http().send() directly in a trigger — throws 'You have uncommitted work pending. Please commit or rollback before calling out.' Always use @future(callout=true) or Queueable with AllowsCallouts.
🏢 XYZ Company Example
XYZ Company's Business Central integration required notifying ERP when new Accounts were created. The @future pattern ensured Account was committed before the ERP notification — no data loss on callout timeout.
Q24
❓ Interviewer Asks
When would you use Queueable Apex instead of @future in a trigger? Write an example with chaining.
✅ Complete Working Code
// AccountTrigger.trigger
trigger AccountTrigger on Account (after insert) {
if (!System.isFuture() && !System.isBatch()) {
Set<Id> ids = new Set<Id>();
for (Account acc : Trigger.new) ids.add(acc.Id);
System.enqueueJob(new AccountSyncQueueable(ids));
}
}
// AccountSyncQueueable.cls
public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
private Set<Id> accountIds;
public AccountSyncQueueable(Set<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [
SELECT Id, Name FROM Account WHERE Id IN :accountIds
];
Set<Id> retryIds = new Set<Id>();
for (Account acc : accounts) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_System/api/sync');
req.setMethod('POST');
req.setBody(JSON.serialize(acc));
new Http().send(req);
} catch (Exception e) {
retryIds.add(acc.Id);
}
}
// Chain next job for retries
if (!retryIds.isEmpty()) {
System.enqueueJob(new AccountSyncQueueable(retryIds));
}
}
}
💡 Why This Approach
Queueable over @future when: passing sObject parameters (not just primitives), needing job chaining for retries, or needing AsyncApexJob monitoring. @future only accepts primitive types — no List, no Map, no sObjects.
⚠️ Common Mistake
Enqueuing a job from @future or batch without checking — throws 'System.AsyncException: Maximum stack depth reached.' Always check !System.isFuture() before enqueuing from a trigger.
🏢 XYZ Company Example
XYZ Company needed to sync Accounts to Business Central with retry logic for failed syncs. @future couldn't chain retries — Queueable chaining handled failed syncs elegantly in the next queued job.
Q25
❓ Interviewer Asks
Write a trigger to implement round-robin lead assignment across 5 sales reps automatically.
✅ Complete Working Code
// Custom Metadata: Round_Robin_Config__mdt
// Field: User_Ids__c = 'userId1,userId2,userId3,userId4,userId5'
// Custom Setting: Round_Robin_Counter__c (Org Default)
// Field: Current_Index__c (Number)
trigger LeadTrigger on Lead (before insert) {
Round_Robin_Config__mdt config = [
SELECT User_Ids__c FROM Round_Robin_Config__mdt
WHERE DeveloperName = 'Lead_Assignment' LIMIT 1
];
List<String> repIds = config.User_Ids__c.split(',');
// Get persistent counter from Custom Setting
Round_Robin_Counter__c counter = Round_Robin_Counter__c.getOrgDefaults();
Integer currentIndex = (Integer)(counter.Current_Index__c ?? 0);
for (Lead lead : Trigger.new) {
if (lead.OwnerId == UserInfo.getUserId()) { // Not manually assigned
lead.OwnerId = Id.valueOf(
repIds[Math.mod(currentIndex, repIds.size())].trim()
);
currentIndex++;
}
}
counter.Current_Index__c = currentIndex;
upsert counter;
}
💡 Why This Approach
Math.mod(index, size) wraps rotation back to 0 when index exceeds list size. Counter stored in Custom Settings — persists across transactions (unlike static variables). Custom Metadata holds the configurable rep list.
⚠️ Common Mistake
Storing the counter in a static variable — resets to 0 every transaction, breaking the rotation completely. The counter MUST be persisted in the database (Custom Settings or custom object) to survive across transactions.
🏢 XYZ Company Example
XYZ Company's 5 territory managers needed equal lead distribution. Manual assignment caused cherry-picking. Round-robin trigger ensured fair distribution — each manager got every 5th lead automatically.
Q26
❓ Interviewer Asks
Write a trigger to handle the Account merge operation. What happens to child records during merge? How do you handle custom objects that don't auto-reparent?
✅ Complete Working Code
trigger AccountTrigger on Account (after delete) {
// During merge: loser records fire after delete
// MasterRecordId on loser = winner Account ID
Map<Id, Id> mergedToMaster = new Map<Id, Id>();
for (Account acc : Trigger.old) {
if (acc.MasterRecordId != null) {
mergedToMaster.put(acc.Id, acc.MasterRecordId);
}
}
if (mergedToMaster.isEmpty()) return;
// Standard objects (Contact, Opp, Case) AUTO-reparent
// Custom object lookups DO NOT — must handle manually
List<Visit_Report__c> reports = [
SELECT Id, Account__c
FROM Visit_Report__c
WHERE Account__c IN :mergedToMaster.keySet()
];
for (Visit_Report__c report : reports) {
report.Account__c = mergedToMaster.get(report.Account__c);
}
if (!reports.isEmpty()) update reports;
}
💡 Why This Approach
During merge, the loser Account fires after delete with MasterRecordId populated. Standard child objects (Contact, Opportunity, Case) auto-reparent to master. Custom objects with lookup fields do NOT — must be manually reparented.
⚠️ Common Mistake
Assuming ALL child records auto-reparent during merge — only standard Salesforce objects do. Custom objects with Account lookup fields are left orphaned (pointing to the deleted Account) without a trigger to handle them.
🏢 XYZ Company Example
When XYZ Company merged duplicate Accounts, Visit_Report__c records became orphaned pointing to the deleted account. This trigger automatically reparented them to the master Account during merge.
Q27
❓ Interviewer Asks
Write a custom rollup trigger — calculate total Closed Won Opportunity Amount per Account in Total_Won_Amount__c. When would you use this over a standard rollup summary?
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (
after insert, after update, after delete, after undelete
) {
Set<Id> accountIds = new Set<Id>();
if (Trigger.new != null) {
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) accountIds.add(opp.AccountId);
}
}
if (Trigger.old != null) {
for (Opportunity opp : Trigger.old) {
if (opp.AccountId != null) accountIds.add(opp.AccountId);
}
}
if (accountIds.isEmpty()) return;
// Fresh totals via AggregateResult
Map<Id, Decimal> accountTotals = new Map<Id, Decimal>();
for (AggregateResult ar : [
SELECT AccountId, SUM(Amount) totalAmt
FROM Opportunity
WHERE AccountId IN :accountIds
AND IsWon = true AND Amount != null
GROUP BY AccountId
]) {
accountTotals.put(
(Id)ar.get('AccountId'),
(Decimal)ar.get('totalAmt')
);
}
List<Account> toUpdate = new List<Account>();
for (Id accId : accountIds) {
toUpdate.add(new Account(
Id = accId,
Total_Won_Amount__c = accountTotals.containsKey(accId)
? accountTotals.get(accId) : 0
));
}
update toUpdate;
}
💡 Why This Approach
Standard Rollup = Master-Detail only, max 40 per object, basic filters. Custom Trigger Rollup = works on Lookup relationships, unlimited, any WHERE clause including date filters. Use custom when standard can't do what you need.
⚠️ Common Mistake
Only handling after insert and after update — forgetting after delete and after undelete. If you delete a Closed Won opp, the total should decrease. Always handle all 4 DML events for rollup triggers.
🏢 XYZ Company Example
XYZ Company needed Account revenue totals filtered by financial year. Standard rollup couldn't filter by date range — this trigger calculated the exact metric needed for management dashboards.
🏢 Section 6 — XYZ Company Scenario Questions
Q28–Q30 · Real Business Scenarios — The Hardest Interview Questions
Q28
❓ Interviewer Asks
At XYZ Company, when Opportunity moves to 'Negotiation' stage — auto-create a Visit Report (Visit_Report__c) with Status='Pending'. Ensure this only happens ONCE per opportunity.
✅ Complete Working Code
trigger OpportunityTrigger on Opportunity (after update) {
// Step 1: Find opps entering Negotiation
List<Id> enteringNegotiation = new List<Id>();
Map<Id, Opportunity> relevantOpps = new Map<Id, Opportunity>();
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
if (opp.StageName == 'Negotiation' &&
oldOpp.StageName != 'Negotiation') {
enteringNegotiation.add(opp.Id);
relevantOpps.put(opp.Id, opp);
}
}
if (enteringNegotiation.isEmpty()) return;
// Step 2: Check existing Visit Reports (prevent duplicates)
Set<Id> alreadyHasReport = new Set<Id>();
for (Visit_Report__c vr : [
SELECT Opportunity__c FROM Visit_Report__c
WHERE Opportunity__c IN :enteringNegotiation
]) {
alreadyHasReport.add(vr.Opportunity__c);
}
// Step 3: Create only where none exists
List<Visit_Report__c> toCreate = new List<Visit_Report__c>();
for (Id oppId : enteringNegotiation) {
if (!alreadyHasReport.contains(oppId)) {
Opportunity opp = relevantOpps.get(oppId);
toCreate.add(new Visit_Report__c(
Opportunity__c = oppId,
Account__c = opp.AccountId,
Status__c = 'Pending',
Visit_Date__c = Date.today(),
OwnerId = opp.OwnerId,
Subject__c = 'Negotiation Visit - ' + opp.Name
));
}
}
if (!toCreate.isEmpty()) insert toCreate;
}
💡 Why This Approach
Detects the EXACT transition (old != Negotiation AND new == Negotiation). Checks for existing Visit Reports before creating — prevents duplicates if stage moves back and forward. The duplicate check is critical for production data integrity.
⚠️ Common Mistake
Not checking for existing Visit Reports — if stage moves back from Negotiation then returns, a second Visit Report is created. Always check for existing related records before auto-creating to prevent duplicates.
🏢 XYZ Company Example
XYZ Company required a Visit Report for every deal entering Negotiation for compliance tracking. This trigger automated creation — saving 2 hours per week in manual admin work across the sales team.
Q29
❓ Interviewer Asks
Write a trigger to prevent Order deletion if it has related ERP Invoice records OR if Order Status is 'Activated'.
✅ Complete Working Code
trigger OrderTrigger on Order (before delete) {
Set<Id> orderIds = new Set<Id>();
for (Order ord : Trigger.old) orderIds.add(ord.Id);
// Count related Invoices using AggregateResult
Map<Id, Integer> invoiceCounts = new Map<Id, Integer>();
for (AggregateResult ar : [
SELECT Order__c, COUNT(Id) cnt FROM Invoice__c
WHERE Order__c IN :orderIds
GROUP BY Order__c
]) {
invoiceCounts.put((Id)ar.get('Order__c'), (Integer)ar.get('cnt'));
}
for (Order ord : Trigger.old) {
// Validation 1: Block if Activated
if (ord.Status == 'Activated') {
ord.addError(
'Cannot delete an Activated Order. Deactivate first.'
);
}
// Validation 2: Block if has Invoices
else if (invoiceCounts.containsKey(ord.Id) &&
invoiceCounts.get(ord.Id) > 0) {
ord.addError(
'Cannot delete Order with ' + invoiceCounts.get(ord.Id) +
' related Invoice(s). Delete the Invoices first.'
);
}
}
}
💡 Why This Approach
AggregateResult with GROUP BY counts Invoices for all orders in one query. Checks Status first (no SOQL needed) — fail fast optimization. Error messages are specific about WHY and what action is needed.
⚠️ Common Mistake
Running SELECT COUNT() FROM Invoice__c WHERE Order__c = :ord.Id inside the for loop — SOQL in loop fails on bulk delete. Always use AggregateResult with GROUP BY for counting related records in bulk.
🏢 XYZ Company Example
XYZ Company's finance team accidentally deleted Orders that had been invoiced in Business Central — causing ERP reconciliation nightmares. This trigger prevented 3 financial audit issues in the first month.
Q30
❓ Interviewer Asks
Write a complete production-ready trigger: when Opportunity closes as Won — (1) update Account's Last_Won_Date__c and increment Won_Deals_Count__c, (2) create a Congratulations Task. Handle all bulk and error scenarios.
✅ Complete Working Code
// OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (after update) {
OpportunityTriggerHandler.handleClosedWon(Trigger.new, Trigger.oldMap);
}
// OpportunityTriggerHandler.cls
public class OpportunityTriggerHandler {
public static void handleClosedWon(
List<Opportunity> newOpps,
Map<Id, Opportunity> oldMap
) {
// STEP 1: Find newly Closed Won opps only
List<Opportunity> closedWonOpps = new List<Opportunity>();
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : newOpps) {
Opportunity oldOpp = oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' &&
oldOpp.StageName != 'Closed Won' &&
opp.AccountId != null) {
closedWonOpps.add(opp);
accountIds.add(opp.AccountId);
}
}
if (closedWonOpps.isEmpty()) return; // Early exit
// STEP 2: Get Accounts
Map<Id, Account> accountMap = new Map<Id, Account>([
SELECT Id, Last_Won_Date__c, Won_Deals_Count__c
FROM Account WHERE Id IN :accountIds
]);
// STEP 3: Track latest date and win count per account
Map<Id, Date> latestWonDate = new Map<Id, Date>();
Map<Id, Integer> newWinCounts = new Map<Id, Integer>();
for (Opportunity opp : closedWonOpps) {
Date d = opp.CloseDate != null ? opp.CloseDate : Date.today();
if (!latestWonDate.containsKey(opp.AccountId) ||
latestWonDate.get(opp.AccountId) < d) {
latestWonDate.put(opp.AccountId, d);
}
Integer c = newWinCounts.containsKey(opp.AccountId)
? newWinCounts.get(opp.AccountId) : 0;
newWinCounts.put(opp.AccountId, c + 1);
}
// STEP 4: Update Accounts (partial success)
List<Account> accsToUpdate = new List<Account>();
for (Id accId : accountIds) {
Account acc = accountMap.get(accId);
if (acc != null) {
acc.Last_Won_Date__c = latestWonDate.get(accId);
acc.Won_Deals_Count__c = (acc.Won_Deals_Count__c ?? 0) +
newWinCounts.get(accId);
accsToUpdate.add(acc);
}
}
Database.update(accsToUpdate, false);
// STEP 5: Create Congratulations Tasks (partial success)
List<Task> congrats = new List<Task>();
for (Opportunity opp : closedWonOpps) {
congrats.add(new Task(
Subject = '🎉 Closed Won: ' + opp.Name,
WhatId = opp.Id,
OwnerId = opp.OwnerId,
ActivityDate = Date.today(),
Status = 'Completed',
Priority = 'High',
Description = 'Congratulations! Deal closed. ' +
'Update Account notes and plan next steps.'
));
}
Database.insert(congrats, false);
}
}
💡 Why This Approach
Production-ready: early exit, bulk-safe Maps, handles multiple Closed Won opps for same account in same transaction, null-safe ?? operator for Won_Deals_Count__c, Database.update/insert with allOrNone=false, handler pattern.
⚠️ Common Mistake
Missing null-safe ?? operator for Won_Deals_Count__c (NPE if field is null), not handling multiple Closed Won opps for same account in same transaction, insert instead of Database.insert (one failure blocks all), no early exit, no handler pattern.
🏢 XYZ Company Example
This trigger runs in production at XYZ Company. Deal closes → Last_Won_Date updates on Account dashboard, Won_Deals_Count increments for quarterly tracking, sales rep gets a Congratulations task. All automated. 🎉
🚀 More Free Salesforce Interview Prep
1,200+ questions across 18 topics — all completely free. No signup. No paywall.
← Part 1: Theory All Topics →