Top 30 Salesforce Apex Trigger Coding Interview Questions 2026 — Write the Code
Top 30 Salesforce Apex Trigger
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.
// 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);
}
}
}
}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.
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%.
trigger ContactTrigger on Contact (before insert) {
for (Contact con : Trigger.new) {
if (String.isBlank(con.Description)) {
con.Description = 'New Contact - ' + Date.today().format();
}
}
}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.
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.
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.'
);
}
}
}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.
XYZ Company's sales managers accidentally deleted Closed Won opps during cleanup. This trigger protected ₹2Cr+ in historical records.
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;
}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.
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.
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;
}
}
}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.
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.
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.'
);
}
}
}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.
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.
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;
}
} Write the 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;
}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.
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.
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;
}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.
XYZ Company used Contact_Count__c on Account list views — enabling 'Accounts with 0 contacts' filters for territory management and data quality checks.
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;
}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.
When XYZ Company reclassified an Account from Manufacturing to Pharmaceutical, all 500+ related Contacts' Department updated automatically in one transaction.
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.');
}
}
}
}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.
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.
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;
}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.
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.
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;
}
}
}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.
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.
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.'
);
}
}
}
}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.
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.
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.'
);
}
}
}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.
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.
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.'
);
}
}
}
}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.
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.
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;
}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.
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.
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.'
);
}
}
}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.
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.
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.'
);
}
}
}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.
XYZ Company's reps deleted Opportunities without realizing they had sent PDF Quotes to customers. This trigger prevented deletion — protecting the quote audit trail.
// 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) { }
}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.
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.
// 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;
}
}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.
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.
// 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.');
}
}
}
}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.
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. 🎉
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()); }
}
}
}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.
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.
// 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());
}
}
}
}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.
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.
// 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));
}
}
}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.
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.
// 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;
}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.
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.
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;
}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.
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.
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;
}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.
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.
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;
}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.
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.
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.'
);
}
}
}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.
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.
// 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);
}
}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.
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. 🎉
Want More Free Salesforce Interview Prep?
sfinterviewpro.com covers everything from Admin basics to Advanced Apex, LWC, Integration, Agentforce, Data Cloud, DevOps and scenario-based questions — all completely free, no signup required.
Visit sfinterviewpro.comPractice with real people
Join the free Mock Interview Community — practice with peers, get honest feedback, and walk into your real interview confident.
Join the Community ↗