This guide walks through 61 intermediate and advanced trigger challenges — rollups, recursion guards, bulkification, and multi-object trigger handlers. Each entry below includes the full problem statement, the governor-limit and best-practice constraints it's testing, and a numbered approach to solving it — then you can open the same problem in ApexArena's browser-based Apex editor and get instant pass/fail feedback against real test cases.
Medium
TriggersSOQL
1. Contact Count Rollup Trigger
Problem #2 · Salesforce Apex Coding Challenge
Problem Statement
After inserting or deleting a Contact, update a custom field
Contact_Count__c on the parent Account.
The field must always reflect the actual count of contacts linked to that account.
Constraints
- Must handle bulk (up to 200 contacts at once)
- Only one SOQL query and one DML statement total
- Handle contacts without an AccountId gracefully
Approach
- 1Use: SELECT AccountId, COUNT(Id) total FROM Contact WHERE AccountId IN :accountIds GROUP BY AccountId
- 2Build a Map from the AggregateResult before updating accounts.
- 3Update all accounts in a single List — one DML call only.
Medium
Triggers
2. Recursive Trigger Guard
Problem #7 · Salesforce Apex Coding Challenge
Problem Statement
Prevent a trigger from running more than once per transaction by implementing a
static boolean guard in a handler class.
This is critical when your trigger performs DML that could re-fire the same trigger,
causing infinite recursion and hitting the governor limit on recursive trigger depth (16).
Requirements
- Create a
TriggerHelper class with a static boolean
- The trigger must check and set the guard on entry
- Write a test proving the trigger runs exactly once even when the DML re-fires it
Approach
- 1Set hasRun = true BEFORE the DML that would re-trigger, not after.
- 2Reset hasRun = false in the @TestSetup or between test methods if testing multiple scenarios.
- 3For multi-object scenarios, use a Set of trigger names instead of a single boolean.
Medium
TriggersSOQL
3. Contact Email Deduplication
Problem #15 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert trigger on Contact that prevents
inserting a contact whose Email already exists on another contact record.
Requirements
- Collect all incoming emails in a
Set
- Run one SOQL query to find existing contacts
- Call
addError() on the Email field for duplicates
- Skip contacts with no email gracefully
Approach
- 1Query: SELECT Email FROM Contact WHERE Email IN :emails
- 2Build a Set from the query results, then check each new contact against it.
- 3Call c.Email.addError('A contact with this email already exists.')
Medium
TriggersSOQL
4. Opportunity Won Revenue Rollup
Problem #17 · Salesforce Apex Coding Challenge
Problem Statement
Write an after update trigger on Opportunity.
When an Opportunity's StageName changes to Closed Won,
recalculate the parent Account.AnnualRevenue as the
sum of all Closed Won opportunity amounts for that account.
Requirements
- Use
Trigger.old to detect the stage change
- One aggregate SOQL query with
SUM(Amount) and GROUP BY AccountId
- One DML
update on the affected accounts
Approach
- 1SOQL: SELECT AccountId, SUM(Amount) total FROM Opportunity WHERE AccountId IN :accountIds AND StageName = 'Closed Won' GROUP BY AccountId
- 2Use ar.get('AccountId') and ar.get('total') to read aggregate results.
- 3Build a Map then update Account.AnnualRevenue in a single DML.
Medium
TriggersSOQL
5. Prevent Duplicate Contacts by Email
Problem #22 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert trigger on Contact that prevents duplicate contacts
with the same Email. If a duplicate is detected, add an error on the Email field.
Constraints
- Must be bulkified — collect all emails, query once
- Use
addError() on the specific field
- Ignore contacts with a blank/null email
Approach
- 1Query: SELECT Email FROM Contact WHERE Email IN :emails
- 2Build a Set of existing emails, then loop Trigger.new to addError on matches.
Medium
TriggersSOQL
6. Auto-Close Stale Opportunities
Problem #25 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert trigger on Task. When a Task with
Subject = 'Close Opportunity' is inserted, set the linked Opportunity's
StageName to "Closed Lost".
Use WhatId to find the Opportunity Id.
Constraints
- Only process Tasks where Subject = 'Close Opportunity'
- Bulkified: one SOQL query, one DML update
Approach
- 1Query: SELECT Id, StageName FROM Opportunity WHERE Id IN :oppIds
- 2Loop the results, set StageName = 'Closed Lost', then call update opps.
Medium
TriggersSOQL
7. Case Count Rollup on Account
Problem #37 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert, after update, after delete trigger on Case
that keeps a custom field Open_Cases__c on Account updated with the
count of open (non-Closed) Cases for that account.
Constraints
- Bulkified — collect Account IDs, one aggregate SOQL, one update
- Status != 'Closed' for open cases
Approach
- 1Use AggregateResult: SELECT AccountId, COUNT(Id) cnt FROM Case WHERE AccountId IN :accountIds AND Status != 'Closed' GROUP BY AccountId
- 2Build a Map from the aggregate result, then loop accountIds to build update list.
Medium
Triggers
8. Dynamic Required Field Enforcement
Problem #44 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert, before update trigger on Contact
that enforces a custom rule: if MailingCountry = 'US', the MailingState
must not be blank.
Add a field error on MailingState if the rule is violated.
Approach
- 1Check: c.MailingCountry == 'US' && String.isBlank(c.MailingState)
- 2c.MailingState.addError('State is required for US addresses.');
Medium
TriggersSOQL
9. Sync Account Phone to Child Contacts
Problem #47 · Salesforce Apex Coding Challenge
Problem Statement
Write an after update trigger on Account that propagates
the Account's Phone to all child Contacts' Phone field
whenever the Account's Phone changes.
Constraints
- Only process Accounts where Phone has changed (
Trigger.oldMap)
- Bulkified — collect IDs, query once, one update DML
Approach
- 1Build a Map of accountId → new phone from Trigger.new.
- 2Query: SELECT Id, AccountId FROM Contact WHERE AccountId IN :changedIds
- 3For each Contact, set Phone = phoneMap.get(c.AccountId); then update.
Medium
Triggers
10. Stage Change Close Date Setter
Problem #78 · Salesforce Apex Coding Challenge
Problem Statement
Write a before update trigger on Opportunity that
automatically sets CloseDate to today when the StageName
changes to "Closed Won".
Use Trigger.oldMap to compare the old and new values — only update
records where the stage actually changed to avoid unnecessary writes.
Key Concept — Trigger.oldMap
Trigger.oldMap is a Map<Id, Opportunity> containing the
previous field values before the save. Access it with
Trigger.oldMap.get(opp.Id) to compare old vs new.
Constraints
- Trigger event: before update only
- Must use
Trigger.oldMap to detect the stage change
- Only set
CloseDate when stage transitions to Closed Won (not when it was already Closed Won)
- Handle bulk (up to 200 records) — no SOQL inside the loop
Example
Old StageName: Prospecting → New StageName: Closed Won → CloseDate = today ✓
Old StageName: Closed Won → New StageName: Closed Won → No change (already won)
Old StageName: Negotiation → New StageName: Needs Analysis → No change
Approach
- 1Use Trigger.oldMap.get(opp.Id) to get the old version of each record.
- 2Condition: opp.StageName == 'Closed Won' && oldOpp.StageName != 'Closed Won'
- 3This is a before trigger — assign opp.CloseDate = Date.today() directly without DML.
Medium
TriggersSOQL
11. Account Deletion Blocker
Problem #80 · Salesforce Apex Coding Challenge
Problem Statement
Write a before delete trigger on Account that
prevents deleting any Account that has related Contact records.
Call addError() on the record to surface a user-friendly message
and block the deletion.
Key Concept — Before Delete Trigger
In a before delete trigger:
Trigger.old contains the records about to be deleted
Trigger.new is always empty — use Trigger.old
- Calling
record.addError('message') blocks the deletion
Trigger.oldMap is a Map<Id, Account> for fast lookup
Constraints
- Collect all Account IDs first, then run one SOQL query (no SOQL in loop)
- Handle bulk: up to 200 accounts being deleted at once
- Gracefully skip accounts that have no contacts
Approach
- 1Query: SELECT AccountId FROM Contact WHERE AccountId IN :accountIds
- 2Build a Set of accountIds that have contacts from the query result.
- 3Call acc.addError('Cannot delete an Account with related Contacts.') to block deletion.
Hard
Triggers
12. Opportunity Undelete Restorer
Problem #81 · Salesforce Apex Coding Challenge
Problem Statement
Write an after undelete trigger on Opportunity that
reactivates opportunities when they are restored from the Recycle Bin.
For each undeleted opportunity with a StageName of "Closed Won"
or "Closed Lost", reset it to active status:
- Set
StageName to "Prospecting"
- Set
CloseDate to today + 30 days
Key Concept — After Undelete Trigger
after undelete is the only valid undelete event. In this context:
Trigger.new contains the restored records (with their Ids)
Trigger.old is null / empty — there is no "before" state for undelete
Trigger.isUndelete is true
- After triggers must use DML to persist field changes (you cannot assign fields directly)
Constraints
- Trigger event:
after undelete only
- Only modify opportunities that were Closed Won or Closed Lost
- Use a single DML update — collect into a list first
- Handle bulk: up to 200 records
Approach
- 1Check: opp.StageName == 'Closed Won' || opp.StageName == 'Closed Lost'
- 2In after triggers you cannot assign to Trigger.new records directly — create a new sObject: new Opportunity(Id=opp.Id, ...)
- 3Set CloseDate = Date.today().addDays(30) on the new Opportunity wrapper.
Medium
TriggersValidation
13. Deactivate Account Validation Trigger
Problem #135 · Salesforce Apex Coding Challenge
Problem Statement
Write a before update trigger on Account that prevents
an account from being deactivated (i.e. IsActive__c set to false)
if any linked Opportunity has a StageName that is
not Closed Won or Closed Lost.
If the condition is violated, throw a field error on IsActive__c
using addError().
Best Practices
- Separate trigger logic into a handler class (
AccountTriggerHandler).
- Use a single SOQL query — no SOQL inside loops.
- Bulkify: handle up to 200 records in one trigger invocation.
- Test class must cover positive, negative, and bulk scenarios.
Objects / Fields
Account — IsActive__c (Checkbox custom field)
Opportunity — AccountId, StageName
Approach
- 1In the handler, build a Set of Account Ids being deactivated (IsActive__c changed from true → false).
- 2Query Opportunities: WHERE AccountId IN :deactivatingIds AND StageName NOT IN ('Closed Won', 'Closed Lost').
- 3Build a Map (hasOpenOpps), then iterate Trigger.new to call acc.IsActive__c.addError(...).
- 4In your test, insert an Account with an open Opportunity and assert that the DML throws an exception.
Medium
TriggersValidation
14. Employee Termination Validation Trigger
Problem #136 · Salesforce Apex Coding Challenge
Problem Statement
Write a before update trigger on the custom object
Employee__c that blocks changing the Status__c field to
"Terminated" if there are no
Employee_Review__c records linked to that employee.
Throw a field error on Status__c via addError().
Best Practices
- Handler class pattern — keep trigger thin.
- Bulkify: one SOQL for all employees being terminated.
- Test: positive (has review → allowed), negative (no review → error), bulk.
Objects / Fields
Employee__c — Status__c (picklist: Active, On Leave, Terminated)
Employee_Review__c — Employee__c (Lookup to Employee__c)
Approach
- 1Collect Ids where Status__c is changing to "Terminated".
- 2Query: SELECT Employee__c, COUNT(Id) cnt FROM Employee_Review__c WHERE Employee__c IN :ids GROUP BY Employee__c.
- 3Build a Set of employee Ids that HAVE reviews. Employees NOT in this set get addError().
- 4Test negative: insert Employee without reviews, try to set Status = Terminated, expect DmlException.
Hard
TriggersSOQL
15. Cross-Object IsActive Checkbox Sync
Problem #137 · Salesforce Apex Coding Challenge
Problem Statement
All three objects — Account, Contact, and User
— have a custom checkbox IsActive__c.
When Account.IsActive__c is unchecked (false):
- Set
IsActive__c = false on all related Contacts of that account.
- Set
IsActive__c = false on the User linked via
Account.OwnerId (only if that user's IsActive__c is currently true).
Best Practices
- Use after update trigger so updates to related records don't affect the in-flight Account.
- Single SOQL per object — no SOQL inside loops.
- Bulkify: multiple accounts can be deactivated at once.
- Test: deactivate account → assert contacts and owner user also deactivated.
Approach
- 1Collect all account Ids where IsActive__c changed true → false.
- 2One query for Contacts: SELECT Id FROM Contact WHERE AccountId IN :ids AND IsActive__c = true.
- 3One query for Users: SELECT Id FROM User WHERE Id IN :ownerIds AND IsActive__c = true.
- 4Update Contacts and Users in separate DML calls. Never mix SObject types in one list.
Medium
TriggersSOQLAggregate
16. Country-wise Account Count Tracker
Problem #139 · Salesforce Apex Coding Challenge
Problem Statement
Create a custom object Country_Account_Count__c with fields
Country__c (Text) and Account_Count__c (Number).
Write an after insert / after update / after delete / after undelete
trigger on Account that keeps this summary object up to date with the
total number of accounts per BillingCountry.
Best Practices
- Use a single SOQL aggregate query to recount after any change.
- Upsert (not insert) the summary records using an external Id or existing Ids.
- Handle blank BillingCountry gracefully — skip or group as "Unknown".
Approach
- 1After the trigger fires, collect affected BillingCountry values from both Trigger.new and Trigger.old.
- 2Run: SELECT BillingCountry, COUNT(Id) cnt FROM Account WHERE BillingCountry IN :countries GROUP BY BillingCountry.
- 3Query existing Country_Account_Count__c records by Country__c to upsert (not duplicate).
- 4Test: insert 3 US + 2 UK accounts, assert counts are correct in the summary object.
Medium
TriggersSOQL
17. Total Contact Count Rollup on Account
Problem #141 · Salesforce Apex Coding Challenge
Problem Statement
Create a custom field No_of_Total_Contact__c (Number) on the
Account object.
Write an after insert / after delete trigger on Contact
that updates No_of_Total_Contact__c on the parent account to always reflect
the live count of associated contacts.
Best Practices
- Collect all affected Account Ids first.
- One aggregate SOQL:
SELECT AccountId, COUNT(Id) cnt FROM Contact WHERE AccountId IN :ids GROUP BY AccountId.
- One DML: update all Account records in a single list.
- Test: insert 5 contacts → count = 5; delete 2 → count = 3.
Approach
- 1Use Trigger.isInsert ? Trigger.new : Trigger.old to get the right list.
- 2After deletion, the remaining contacts are still in the database — re-query the count.
- 3An account with no contacts left should have No_of_Total_Contact__c = 0.
- 4Test covers insert, delete, and contacts without AccountId (edge case).
Hard
TriggersSOQL
18. Sync Email Field Across Sibling Opportunities
Problem #142 · Salesforce Apex Coding Challenge
Problem Statement
An Account has 10 linked Opportunities.
There is a custom field Contact_Email__c (Email) on Opportunity.
When a user updates Contact_Email__c on any one of those
opportunities, automatically propagate that value to all other sibling
opportunities linked to the same account.
Best Practices
- Use after update — sibling updates must happen after the original is committed.
- Only propagate when
Contact_Email__c actually changed.
- Exclude the triggering opportunity itself to avoid a circular update.
- One SOQL, one DML — no queries inside loops.
Approach
- 1Build a Map of AccountId → new email for Opps where Contact_Email__c changed.
- 2Query all sibling Opps: SELECT Id, AccountId, Contact_Email__c FROM Opportunity WHERE AccountId IN :changedAccountIds AND Id NOT IN :triggerIds.
- 3Update siblings where acc email differs from current value.
- 4Test: update email on one opp → assert all siblings updated; verify the triggering opp is NOT re-updated.
Hard
TriggersSOQLAutomation
19. Auto-Reassign Accounts & Opportunities on User Deactivation
Problem #143 · Salesforce Apex Coding Challenge
Problem Statement
When a Salesforce User is deactivated (IsActive set to
false), automatically reassign all of that user's Accounts
and Opportunities to the deactivated user's Manager
(User.ManagerId).
Best Practices
- Use before update — changing OwnerId on User is only possible
via System.runAs() or mixed DML workaround in tests.
- Use
@future method to update Account and Opportunity records
(mixed DML — you cannot update User and related non-setup objects in the
same transaction).
- One SOQL per object, one DML per object.
- Skip users with no ManagerId.
Approach
- 1Collect deactivating user Ids (IsActive true→false) and their ManagerId from Trigger.new.
- 2Pass a JSON string (or two Set params) to @future since you cannot pass SObjects to future methods.
- 3In the future method, query Accounts and Opportunities where OwnerId IN :deactivatedIds.
- 4Test: create Manager + User, assign records, deactivate User, assert records moved to Manager.
Medium
TriggersApex ClassesBusiness Logic
20. Trigger: Set Closed-Won Date on Opportunity Stage Change
Problem #151 · Salesforce Apex Coding Challenge
Problem Statement
Your revenue operations team needs to know exactly when each opportunity
was first won. When an Opportunity's StageName is changed to
'Closed Won', automatically stamp the current date into a custom field
Closed_Won_Date__c (Date).
Requirements
- Use a before update trigger so the field is set before the record is saved.
- Only set
Closed_Won_Date__c when StageName changes
to 'Closed Won' (not if it was already 'Closed Won').
- Handle bulk updates — no SOQL or DML inside the trigger body.
- Delegate logic to a handler class
OpportunityTriggerHandler.
Best Practices
- One trigger per object — delegate everything to a handler class.
- Use
Trigger.oldMap to detect field changes.
- In tests, use
Test.startTest() / Test.stopTest() to isolate the trigger context.
Approach
- 1Compare opp.StageName == 'Closed Won' with oldOpp.StageName != 'Closed Won' to detect the transition.
- 2Set opp.Closed_Won_Date__c = Date.today() inside the handler — no DML needed (before trigger).
- 3Test the negative case: if StageName was already 'Closed Won', the date should NOT be re-stamped.
- 4Bulk test: insert 200 opps, update all to 'Closed Won', assert all have today's date.
Medium
TriggersSOQLRollup
21. Trigger: Rollup Contact Count onto Account
Problem #152 · Salesforce Apex Coding Challenge
Problem Statement
Your reporting team needs a real-time count of contacts per account stored in a
custom field Contact_Count__c (Number) on the Account object.
Write a trigger ContactTrigger on the Contact object that
keeps Account.Contact_Count__c accurate after inserts
and deletes.
Requirements
- Fire on
after insert and after delete
- Use a single aggregate SOQL query:
SELECT AccountId, COUNT(Id) cnt FROM Contact WHERE AccountId IN :ids GROUP BY AccountId
- Update
Account.Contact_Count__c for all affected accounts in one DML
- Delegate logic to
ContactTriggerHandler
Best Practices
- Collect all affected AccountIds before the query — no SOQL inside loops.
- Use AggregateResult to read the count:
(Integer) ar.get('cnt').
- Test both insert and delete paths to verify the count increments and decrements.
Approach
- 1Collect AccountIds from the trigger list first, then run one aggregate SOQL.
- 2AggregateResult query: SELECT AccountId, COUNT(Id) cnt FROM Contact WHERE AccountId IN :accountIds GROUP BY AccountId.
- 3Cast the count: Integer cnt = (Integer) ar.get('cnt'); then set acc.Contact_Count__c = cnt.
- 4Test delete: insert 2 contacts, delete 1, then reload the Account and assert Contact_Count__c = 1.
Hard
TriggersApex ClassesAutomation
22. Trigger: Auto-Create Follow-Up Task for High Priority Cases
Problem #153 · Salesforce Apex Coding Challenge
Problem Statement
Your support team requires that whenever a High priority case is created,
a follow-up Task is automatically generated for the case owner reminding them
to respond within 2 hours.
Write a trigger CaseTrigger on the Case object and a handler
class CaseTriggerHandler that, on after insert:
- Identifies all newly inserted cases where
Priority = 'High'
- Creates one
Task per high-priority case with:
Subject = 'Follow-up: ' + case.CaseNumber
WhatId = case Id (links Task to the Case)
OwnerId = case OwnerId
ActivityDate = Date.today()
Priority = 'High'
Status = 'Not Started'
- Inserts all tasks in a single bulk DML
Best Practices
- Use after insert — Task.WhatId requires the Case to already have an Id.
- Filter for
Priority = 'High' before creating tasks — do not create tasks for Normal/Low cases.
- Collect all Task records first, then insert once — never DML inside a loop.
- Test that Normal priority cases do NOT generate tasks.
Approach
- 1Use after insert — you need the Case Id to populate Task.WhatId.
- 2Loop newCases, check c.Priority == 'High', build a Task and add to list.
- 3Task fields: Subject = 'Follow-up: ' + c.CaseNumber, WhatId = c.Id, OwnerId = c.OwnerId.
- 4Insert the task list once outside the loop — single DML for all tasks.
- 5Test the negative: insert a Normal case and assert [SELECT COUNT() FROM Task WHERE WhatId = :normalCase.Id] == 0.
Medium
Triggers
23. Trigger: Auto-Calculate Lead Score
Problem #156 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert / before update trigger on Lead
with handler LeadTriggerHandler that computes Lead_Score__c:
| Field | Condition | Points |
| LeadSource | Web | +10 |
| LeadSource | Phone Inquiry | +8 |
| LeadSource | Other | +5 |
| Title | Contains 'VP' or 'Director' | +15 |
| Title | Contains 'Manager' | +8 |
| Company | Not blank | +5 |
| Email | Not blank | +5 |
Scores are cumulative. Web + VP + Company + Email = 35 points.
Best Practices
- Reset score to 0 each iteration — idempotent on update.
- Use
String.isNotBlank() for null-safe string checks.
- Use
containsIgnoreCase() for Title matching.
Approach
- 1Start each loop iteration with: Integer score = 0;
- 2Use String.isNotBlank(lead.LeadSource) before comparing the value.
- 3For Title: lead.Title.containsIgnoreCase('VP') || lead.Title.containsIgnoreCase('Director')
- 4Test: LeadSource=Web + Title=VP + Company + Email should score exactly 35.
Medium
TriggersValidation
24. Trigger: Block Opportunity Stage Regression
Problem #157 · Salesforce Apex Coding Challenge
Problem Statement
Once an Opportunity reaches Closed Won or
Closed Lost, it must never be moved back to an open stage.
Write a before update trigger that delegates to
OpportunityTriggerHandler.preventStageRegression() and calls
addError('Cannot reopen a closed Opportunity.') when violated.
Best Practices
- Define a
Set<String> CLOSED_STAGES constant.
- Use
Trigger.oldMap to read the previous StageName.
- Only call addError when StageName actually changed to an open stage from a closed one.
Approach
- 1CLOSED_STAGES = new Set{'Closed Won', 'Closed Lost'}
- 2Detect regression: CLOSED_STAGES.contains(oldStage) && !CLOSED_STAGES.contains(newStage)
- 3Call opp.StageName.addError('Cannot reopen a closed Opportunity.')
- 4Test: insert Closed Won opp, update to Prospecting → expect DmlException.
Medium
TriggersValidation
25. Trigger: Prevent Duplicate Contact Email
Problem #158 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert / before update trigger on Contact
that prevents two contacts from sharing the same Email.
If a duplicate is found, call Email.addError('A Contact with this email already exists.')
Logic
- Collect non-blank emails from
Trigger.new.
- Run one SOQL: existing contacts with matching email, excluding records being updated
(
Id NOT IN :Trigger.newMap.keySet()).
- Build a
Set<String> of existing emails (lowercased).
- Loop and call
addError() on duplicates.
Best Practices
- One SOQL total — never inside a loop.
- Case-insensitive: use
.toLowerCase().
- Self-update safe: exclude the record being updated from SOQL.
Approach
- 1Build Set emailsToCheck from Trigger.new where Email is not blank.
- 2SOQL: SELECT Email FROM Contact WHERE Email IN :emailsToCheck AND Id NOT IN :newIds
- 3Lowercase both existing and incoming emails before comparing.
- 4Test: insert Contact A, insert Contact B with same email → DmlException.
Medium
Triggers
26. Trigger + Handler: Update Campaign Stats on Closed Won
Problem #159 · Salesforce Apex Coding Challenge
Problem Statement
When an Opportunity moves to Closed Won, increment
its linked Campaign.Won_Opportunities__c (Number) by 1.
Write OpportunityTrigger (after update) and
OpportunityTriggerHandler.
Logic
- Find opps where StageName changed to 'Closed Won' and
CampaignId != null.
- Collect unique CampaignIds.
- Query campaigns once:
SELECT Id, Won_Opportunities__c FROM Campaign WHERE Id IN :campaignIds.
- Increment each campaign's counter and update in one DML.
Best Practices
- One SOQL, one DML — never inside loops.
- Compare old vs new StageName to avoid double-counting.
- Initialise
Won_Opportunities__c to 0 if null before incrementing.
Approach
- 1Detect change: opp.StageName == 'Closed Won' && oldMap.get(opp.Id).StageName != 'Closed Won'
- 2SOQL: [SELECT Id, Won_Opportunities__c FROM Campaign WHERE Id IN :campaignIds]
- 3Null-safe increment: (camp.Won_Opportunities__c == null ? 0 : camp.Won_Opportunities__c) + 1
- 4Test: create Campaign → create Opp → update to Closed Won → assert Won_Opportunities__c == 1.
Hard
TriggersValidation
27. Trigger + Handler: Escalate Case Priority on SLA Breach
Problem #160 · Salesforce Apex Coding Challenge
Problem Statement
Write a before update trigger on Case with handler
CaseTriggerHandler. When a Case's Status is set to
'Escalated':
- Set
Priority to 'High'.
- Set
Escalation_Reason__c to 'SLA Breach — auto-escalated'.
- If the case was already
'Escalated' →
addError('Case is already escalated.').
Best Practices
- Use
Trigger.oldMap to compare old Status.
- No SOQL — all data in Trigger.new / oldMap.
- Test: valid escalation, double escalation, non-escalated change, bulk 50.
Approach
- 1Valid escalation: c.Status == 'Escalated' && oldMap.get(c.Id).Status != 'Escalated'
- 2Double escalation: oldMap.get(c.Id).Status == 'Escalated' → addError('Case is already escalated.')
- 3Set Priority = 'High' and Escalation_Reason__c in the valid branch.
- 4Bulk mixed: use Database.update(list, false) and check SaveResult[] for partial failures.
Medium
Triggers
28. Trigger + Handler: Auto-Assign Case Owner by Support Region
Problem #212 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert / before update trigger on Case
with handler CaseTriggerHandler that assigns the OwnerId to the
matching support queue based on a custom Region__c field.
Logic
- Collect unique
Region__c values from Trigger.new.
- Query
Group (queues) where Type = 'Queue' and
Name IN :regions.
- Build a
Map<String, Id> of region name → queue Id.
- Loop
Trigger.new and set c.OwnerId when a match exists.
Best Practices
- One SOQL for queues — never inside the loop.
- Guard with
regions.isEmpty() before the SOQL.
- Trigger delegates entirely to handler — zero business logic in the trigger file.
- Test covers null Region__c (graceful degradation) and the update path.
Approach
- 1Queues are stored as Group records: [SELECT Id, Name FROM Group WHERE Type = 'Queue' AND Name IN :regions]
- 2Build map: Map regionToQueueId = new Map(); for (Group g : ...) regionToQueueId.put(g.Name, g.Id);
- 3Assign: if (regionToQueueId.containsKey(c.Region__c)) c.OwnerId = regionToQueueId.get(c.Region__c);
- 4Trigger should only contain: CaseTriggerHandler.assignOwnerByRegion(Trigger.new);
Medium
TriggersValidation
29. Trigger + Handler: Block Account Deletion When Open Opportunities Exist
Problem #213 · Salesforce Apex Coding Challenge
Problem Statement
Write a before delete trigger on Account that prevents
deletion when there are open (non-closed) Opportunities linked to the Account.
Logic
- Collect all Account Ids from
Trigger.old.
- Query open Opportunities:
WHERE AccountId IN :accountIds AND IsClosed = false.
- Build a
Map<Id, List<Opportunity>> grouped by AccountId.
- Call
acc.addError('Cannot delete Account: N open Opportunity(ies)...') for each match.
Best Practices
- One SOQL — never inside a loop.
- Message includes the count of blocking records for user clarity.
- Test covers 3 scenarios: blocked (open opp), allowed (no opp), allowed (closed opp only).
Approach
- 1Before delete: use Trigger.old (Trigger.new is not available for delete triggers).
- 2Query: [SELECT Id, AccountId FROM Opportunity WHERE AccountId IN :accountIds AND IsClosed = false]
- 3addError: acc.addError('Cannot delete Account: open opportunities exist.');
- 4Test the blocked path by expecting DmlException in a try-catch block.
Hard
Triggers
30. Trigger + Handler: Rollup Closed-Won Revenue onto Account
Problem #214 · Salesforce Apex Coding Challenge
Problem Statement
Write OpportunityRevenueRollupTrigger (after insert/update/delete) and
OpportunityRevenueRollupHandler that maintains
Total_Closed_Won_Revenue__c on Account — the sum of Amount for all
Closed Won Opportunities.
Logic
- Trigger collects affected Account Ids (from Trigger.new AND Trigger.old for updates,
Trigger.old for deletes).
- Handler uses one
AggregateResult SOQL with SUM(Amount) GROUP BY AccountId.
- Builds Map of AccountId → total revenue, queries Accounts, updates only changed records.
Best Practices
- After update: include both old and new AccountIds — the opp may have been
moved to a different account.
- Use
AggregateResult — never sum in a loop.
- Skip records where the value has not changed.
- Test covers: insert (revenue increases), delete (revenue decreases), open opp (not counted).
Approach
- 1Trigger: for delete, use Trigger.old; for insert/update use Trigger.new. For updates also include Trigger.old AccountIds.
- 2Aggregate: [SELECT AccountId, SUM(Amount) total FROM Opportunity WHERE AccountId IN :ids AND IsClosed = true AND IsWon = true GROUP BY AccountId]
- 3Read aggregate value: (Decimal) ar.get('total')
- 4Zero-revenue accounts: use revenueByAccount.containsKey(acc.Id) ? ... : 0 to handle accounts with no won opps.
Medium
Triggers
31. Apex Trigger: Auto-Set Opportunity Probability on Stage Change
Problem #218 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert / before update trigger on Opportunity
that automatically sets Probability based on StageName.
Stage → Probability Mapping
| Stage | Probability |
| Prospecting | 10 |
| Qualification | 20 |
| Needs Analysis | 30 |
| Value Proposition | 40 |
| Id. Decision Makers | 50 |
| Perception Analysis | 60 |
| Proposal/Price Quote | 70 |
| Negotiation/Review | 80 |
| Closed Won | 100 |
| Closed Lost | 0 |
Best Practices
- Store the mapping as a
final Map<String, Integer> constant.
- On updates: skip records where
StageName has not changed to avoid
overwriting manual overrides.
- Use
Trigger.oldMap to detect stage transitions.
- Check
containsKey() before calling get().
- No SOQL or DML needed — pure field assignment.
Approach
- 1Declare: final Map STAGE_PROBABILITY = new Map{ 'Prospecting' => 10, ... };
- 2Skip unchanged stages on update: if (Trigger.isUpdate && opp.StageName == Trigger.oldMap.get(opp.Id).StageName) continue;
- 3Safe lookup: if (STAGE_PROBABILITY.containsKey(opp.StageName)) opp.Probability = STAGE_PROBABILITY.get(opp.StageName);
- 4Trigger.oldMap is only available on update and delete events — guard with Trigger.isUpdate.
Medium
TriggersValidation
32. Trigger + Handler: Prevent Duplicate Lead Email on Insert and Update
Problem #227 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert / before update trigger on Lead
with handler LeadDuplicateEmailHandler that prevents two unconverted Leads
from sharing the same email address.
Logic
- Skip blank emails and unchanged emails on update.
- Query existing unconverted Leads with matching emails, excluding the records
being updated (
Id NOT IN :currentIds).
- Call
l.Email.addError('A Lead with this email address already exists.')
on duplicates.
Best Practices
- Normalise to lowercase before comparing emails.
- Exclude the current record(s) from the duplicate query on updates.
- Use
String.isBlank() — handles null AND empty strings.
- One SOQL — never inside a loop.
Approach
- 1Normalise: emailsToCheck.add(l.Email.toLowerCase()) — avoids case-sensitivity false positives.
- 2Exclude self: AND Id NOT IN :currentIds — where currentIds = oldMap != null ? oldMap.keySet() : new Set()
- 3Field error: l.Email.addError('message') — highlights the Email field in the UI.
- 4Test: update same email should NOT error — verify with an update-same-email test case.
Medium
Triggers
33. Apex Trigger: Auto-Populate Contact Description from Account on Insert
Problem #231 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert trigger on Contact that copies
the parent Account's Description into the Contact's Description
field — but only when the Contact's Description is blank.
Requirements
- Fires on
before insert only.
- Collect Account Ids from Contacts that have an AccountId.
- Guard empty set before the SOQL query.
- Query Accounts (
Id, Description) in one SOQL.
- Build a
Map<Id, Account>.
- In second loop: skip Contacts with no AccountId; skip if Contact already has a
Description; copy Account Description.
Best Practices
- Two loops — collect then apply — keeps SOQL outside the loop.
- Never overwrite a manually set Contact Description.
- Guard with
isEmpty() before the SOQL.
Approach
- 1Collect: for (Contact c : Trigger.new) { if (c.AccountId != null) accountIds.add(c.AccountId); }
- 2Map: Map accountMap = new Map([SELECT Id, Description FROM Account WHERE Id IN :accountIds]);
- 3Skip existing: if (c.Description != null) continue;
- 4Copy: c.Description = acc.Description; — this works in before insert because the record hasn't been committed yet.
Medium
Apex Trigger
34. Trigger + Handler: Auto-Format Contact Phone on Insert/Update
Problem #285 · Salesforce Apex Coding Challenge
Problem Statement
Implement a Trigger + Handler that auto-formats the
Phone field on Contact to US format
(XXX) XXX-XXXX whenever a 10-digit number is entered.
Trigger Requirements
- Fire on before insert and before update
- Contains no business logic — delegates entirely to handler
Handler Requirements
- Skip records where
Phone has not changed on update (use oldMap)
- Strip all non-digit characters, then format if exactly 10 digits
- If not 10 digits or blank, leave the value unchanged
- Mark the private format helper
@TestVisible
Test Class Requirements
- Cover: insert formats phone, update skips unchanged phone, invalid phone unchanged, null phone skipped
- Use
Test.startTest()/stopTest()
Approach
- 1In the trigger, call only one line: ContactPhoneHandler.formatPhones(Trigger.new, Trigger.oldMap).
- 2In the handler loop, check oldMap != null && old.Phone == con.Phone before skipping.
- 3Use replaceAll('[^0-9]', '') to strip non-digit characters.
- 4Mark the private helper @TestVisible so your test class can call it directly.
Medium
Apex Trigger
35. Trigger + Handler: Block Opportunity Close When Amount Is Null
Problem #286 · Salesforce Apex Coding Challenge
Problem Statement
Implement a Trigger + Handler that prevents an
Opportunity from being set to a closed stage
(Closed Won or Closed Lost) when
Amount is null.
Trigger Requirements
- Fire on before insert and before update
- Zero logic in the trigger body — one call to the handler
Handler Requirements
- Define closed stages in a
Set constant
- Skip records that are not in a closed stage
- Skip records where Amount is not null
- Call
opp.Amount.addError() with a clear message
Test Class Requirements
- Assert DmlException thrown for Closed Won with null Amount
- Assert open-stage Opportunity with null Amount saves successfully
- Assert Closed Won with a non-null Amount saves successfully
Approach
- 1Store closed stages in a static Set constant for O(1) lookups.
- 2Use CLOSED_STAGES.contains(opp.StageName) to check if the stage is closed.
- 3Call addError on the specific field: opp.Amount.addError('...') for a field-level error.
- 4In the test, catch DmlException and assert on e.getMessage().
Hard
Apex Trigger
36. Trigger + Handler: Rollup Open Opportunity Count onto Account
Problem #288 · Salesforce Apex Coding Challenge
Problem Statement
Implement a Trigger + Handler that maintains an
Open_Opportunity_Count__c field on Account
reflecting the number of open (non-closed) Opportunity records
linked to each account.
Trigger Requirements
- Fire on after insert, after update, after delete, after undelete
- Use separate method calls per event —
if (Trigger.isInsert), if (Trigger.isUpdate), etc.
- Pass
Trigger.new / Trigger.old and Trigger.oldMap — never pass Trigger.isDelete as a parameter
Handler Requirements
- One public method per event (
onAfterInsert, onAfterUpdate, onAfterDelete, onAfterUndelete)
- Each public method delegates to a private
updateCounts method
- Collect all affected Account IDs from both the records list and
oldMap (handles reparenting)
- Query open opportunities with
IsClosed = false grouped by AccountId using AggregateResult
- Initialise all accounts to 0 in the map before applying query results (handles deletions correctly)
- Single SOQL query — bulk-safe for 200+ records
Test Class Requirements
- Assert count increments on opportunity insert
- Assert count decrements on opportunity delete
Approach
- 1Use separate handler methods per event (onAfterInsert, onAfterUpdate, etc.) — this makes each method independently testable without a trigger context.
- 2Initialise countMap with 0 for every affected account before the query — this ensures deleted opps result in 0, not a missing key.
- 3Use AggregateResult with COUNT(Id) and GROUP BY AccountId in a single SOQL.
- 4In onAfterUpdate, pass oldMap so updateCounts can collect AccountIds from both new and old records (handles reparenting).
Medium
Apex Trigger
37. Trigger + Handler: Block Contact Deletion When Linked to Closed Won Opportunity
Problem #289 · Salesforce Apex Coding Challenge
Problem Statement
Implement a Trigger + Handler that prevents deletion of
a Contact if that contact is linked (via
OpportunityContactRole) to a Closed Won
Opportunity.
Trigger Requirements
- Fire on before delete only
- Pass
Trigger.old to the handler
Handler Requirements
- Collect all Contact IDs being deleted into a
Set
- Single SOQL on
OpportunityContactRole filtering
ContactId IN :contactIds and
Opportunity.StageName = 'Closed Won'
- Call
con.addError() with a clear message for blocked contacts
Approach
- 1Query OpportunityContactRole and filter on Opportunity.StageName using a cross-object filter.
- 2Build a Set of blocked contact IDs from the query result, then iterate oldList to call addError.
- 3One SOQL is enough — no need to query Opportunity separately.
- 4In the test, create an OpportunityContactRole to link the contact to the opportunity.
Medium
Triggers
38. Trigger: Maintain Contact Count Rollup on Account
Problem #326 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger UpdateContactCount on the Contact
object that fires on after insert, after delete, and
after undelete.
After each event, recalculate the number of Contacts for each affected Account and
store the result in a custom field Total_Contacts__c on the Account.
Requirements
- Use a single aggregate SOQL query — no queries inside loops.
- Handle the case where an Account's last Contact is deleted (count → 0).
- Collect Account IDs from
Trigger.new on insert/undelete and from
Trigger.old on delete.
Approach
- 1Use
Trigger.isDelete ? Trigger.old : Trigger.new to pick the right list. - 2Aggregate query:
SELECT AccountId, COUNT(Id) cnt FROM Contact WHERE AccountId IN :accountIds GROUP BY AccountId. - 3Store results in
Map<Id, Integer>. If an account has no rows in the result, its count is 0. - 4Build
new Account(Id = accId, Total_Contacts__c = count) and call a single update.
Medium
Triggers
39. Trigger: Enforce Unique Email Across All Leads
Problem #327 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger LeadUniqueEmailCheck on the Lead
object that fires before insert and before update.
If any incoming Lead has an Email that already exists on another Lead record
(case-insensitive comparison), block the save with:
"Email must be unique across all Leads."
Requirements
- Case-insensitive comparison (use
.toLowerCase()).
- On update, do not flag a Lead against itself.
- Bulk-safe: one SOQL query for all emails in the batch.
Approach
- 1Normalise emails with
.toLowerCase() when building the set and when comparing. - 2Query:
SELECT Id, Email FROM Lead WHERE Email IN :emailSet. - 3Skip the record itself on update:
existingLead.Id != newLead.Id. - 4Call
newLead.addError('Email must be unique across all Leads.').
Medium
Triggers
40. Trigger: Cascade Account Billing Address to Related Contacts
Problem #328 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger UpdateContactBillingAddress on Account
that fires after update.
Whenever any billing address field changes on an Account, copy the new billing
address to the mailing address of all related Contact records.
Address Fields to Sync
- Account:
BillingStreet, BillingCity, BillingState, BillingPostalCode, BillingCountry
- Contact:
MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry
Requirements
- Only update Contacts when at least one billing field actually changed.
- Use
Trigger.oldMap to detect changes.
- Bulk-safe — one SOQL and one DML for all affected Accounts.
Approach
- 1Compare each billing field with its old value:
acc.BillingStreet != old.BillingStreet. - 2Collect changed Account IDs into a
Set<Id> then query all their Contacts once. - 3Build a
Map<Id, Account> from Trigger.newMap so you can look up the new address per Contact. - 4Assign all five mailing fields on each Contact before adding to the update list.
Hard
Triggers
41. Trigger: Rollup Sum from Child Custom Object to Parent
Problem #329 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger CalculateTotalOnCustomObjectA on
CustomObjectB__c that fires on after insert,
after update, and after delete.
Each CustomObjectB__c record has a lookup to CustomObjectA__c
(field: CustomObjectA__c) and a currency field Value__c.
After every change, recalculate and store the sum of all Value__c
for each parent in the Total_Value__c field on CustomObjectA__c.
Requirements
- Use an aggregate SOQL with
SUM() and GROUP BY.
- Handle delete: use
Trigger.old to collect parent IDs.
- Single SOQL and single DML.
Approach
- 1Use
Trigger.isDelete ? Trigger.old : Trigger.new to pick the right list. - 2Aggregate:
SELECT CustomObjectA__c, SUM(Value__c) totalValue FROM CustomObjectB__c WHERE CustomObjectA__c IN :parentIds GROUP BY CustomObjectA__c. - 3Cast the result:
(Id)r.get('CustomObjectA__c') and (Decimal)r.get('totalValue'). - 4Build
new CustomObjectA__c(Id = parentId, Total_Value__c = sum) and update in one DML call.
Medium
Triggers
42. Trigger: Flag Out_of_Zip__c When Contact Postal Code Differs
Problem #331 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger FlagOutOfZip on Account that
fires before update.
When an Account's BillingPostalCode changes, check all related Contacts.
If any Contact's MailingPostalCode does not match the Account's
(new) BillingPostalCode, set Out_of_Zip__c = true
on the Account; otherwise set it to false.
Requirements
- Only process Accounts where
BillingPostalCode actually changed.
- Bulk-safe: one SOQL query for all affected Accounts.
- This is a before trigger — mutate
acc.Out_of_Zip__c directly.
Approach
- 1Compare
acc.BillingPostalCode != Trigger.oldMap.get(acc.Id).BillingPostalCode. - 2Query:
SELECT AccountId, MailingPostalCode, Account.BillingPostalCode FROM Contact WHERE AccountId IN :changedIds. - 3Build a
Map<Id, Boolean> — true if any Contact postal code differs. - 4Since this is a before trigger, update
acc.Out_of_Zip__c directly in the Trigger.new loop — no DML needed.
Medium
Triggers
43. Trigger: Auto-Create Parent Account for Orphaned Contacts
Problem #333 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger CreateAccountForOrphanContact on Contact
that fires before insert.
If a Contact is being inserted with no AccountId, automatically create a
parent Account using the Contact's LastName as the Account name
and Phone as the Account phone, then assign the new Account's Id back to the
Contact's AccountId.
Requirements
- A before-insert trigger allows direct field assignment — no Contact update DML needed.
- Create all Accounts in one bulk DML insert.
- Maintain order to correctly map each orphan Contact to its new Account.
Approach
- 1Build
new Account(Name = con.LastName, Phone = con.Phone) and add to newAccounts. - 2Track the orphan Contact in a parallel
orphans list to keep index alignment. - 3After insert, loop with index:
orphans[i].AccountId = newAccounts[i].Id. - 4Because this is a before-insert trigger, assigning
AccountId on the Contact in Trigger.new is sufficient — no extra DML.
Hard
Triggers
44. Trigger: Flag needintel__c When 70%+ Contacts Are Inactive
Problem #334 · Salesforce Apex Coding Challenge
Problem Statement
Write a trigger UpdateContact on Contact (after update) and
a handler class ContactHandler with a static method
checkDeadIntel(List<Contact>).
When Contacts are updated, check each related Account: if 70% or more
of its Contacts have Dead__c = true, set needintel__c = true on
that Account.
Requirements
- Use two separate aggregate queries — one for total count, one for dead count.
- Calculate:
(deadCount * 100 / totalCount) >= 70.
- Bulk-safe: handle multiple Contacts from multiple Accounts.
- Single Account update DML.
- Write a test class with at least two test methods verifying both cases.
Approach
- 1Total query:
SELECT AccountId, COUNT(Id) cnt FROM Contact WHERE AccountId IN :accIds GROUP BY AccountId. - 2Dead query: same as total but add
AND Dead__c = true to the WHERE clause. - 3Percentage check:
if (total > 0 && (dead * 100 / total) >= 70) — integer division. - 4Build
new Account(Id = accId, needintel__c = true) and update in a single DML call. - 5In the test class use
Test.startTest() / Test.stopTest() around the update DML to trigger async governor-limit reset.
Medium
Triggers
45. Trigger: Update Contact Profile URL When Account Website Changes
Problem #336 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger UpdateContactProfile on Account
that fires after update.
When an Account's Website field changes, update the Profile__c
field on all related Contacts using this format:
Profile__c = Website + "/" + FirstInitial + LastName
Example: Website = https://acme.com, Contact = John Smith
→ Profile__c = "https://acme.com/JSmith"
Requirements
- Only process Accounts where
Website actually changed and is not null.
- Bulk-safe — one SOQL, one DML.
- Skip Contacts with a null
FirstName.
Approach
- 1Check:
acc.Website != Trigger.oldMap.get(acc.Id).Website && acc.Website != null. - 2The SOQL already retrieves
Account.Website — use con.Account.Website in the loop. - 3Build the URL:
con.Account.Website + '/' + con.FirstName.substring(0,1) + con.LastName. - 4Skip nulls with
if (con.FirstName == null) continue;.
Medium
Triggers
46. Trigger: Flag is_gold__c on Accounts with Opportunities Over $20,000
Problem #337 · Salesforce Apex Coding Challenge
Problem Statement
Write an Apex trigger MarkGoldAccount on Opportunity
that fires on after insert, after update, and
after delete.
After each event, check each related Account: if any of its Opportunities has
Amount > 20000, set is_gold__c = true on the Account;
otherwise set it to false.
Requirements
- Use a single aggregate SOQL with
COUNT() and a HAVING
clause — or a regular query with Amount > 20000.
- Handle the delete case: use
Trigger.old to collect Account IDs.
- Bulk-safe: one SOQL and one DML for all affected Accounts.
Approach
- 1Query:
SELECT AccountId FROM Opportunity WHERE AccountId IN :accountIds AND Amount > 20000. - 2Collect the result AccountIds into a
Set<Id> called goldAccIds. - 3For every accountId:
is_gold__c = goldAccIds.contains(accId) — this sets it true OR false correctly. - 4Update all Accounts in a single DML call.
Medium
TriggersAutomation
47. Copy Account Billing Address to New Contact
Problem #370 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert trigger on the standard object Contact
that copies the parent Account's Billing address into the Contact's Mailing
address fields, but only when the Contact's own mailing address is blank.
Best Practices
- Collect the relevant
AccountIds into a Set first.
- Query all needed Accounts in a single SOQL call, then loop the Contacts again to apply the fields.
Objects / Fields
Contact — AccountId, MailingStreet/City/State/PostalCode/Country
Account — BillingStreet/City/State/PostalCode/Country
Approach
- 1Only collect AccountIds for Contacts where MailingStreet AND MailingCity are both null.
- 2Query the Accounts in one call: SELECT BillingStreet, BillingCity, ... FROM Account WHERE Id IN :accountIds.
- 3Build a Map so the second loop can look up each Contact's Account in O(1).
- 4Test: a blank-address Contact (gets copied), a Contact with its own address (unchanged), and a bulk mix.
Medium
TriggersAutomation
48. Auto-Create Follow-Up Task for High-Priority Cases
Problem #371 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert trigger on the standard object Case
that automatically creates a follow-up Task for the Case owner whenever a new
Case is logged with Priority = 'High'.
Best Practices
- Build the full list of Tasks first, then insert once — never insert inside the loop.
- after insert is required since the Case needs an Id to link the Task via
WhatId.
Objects / Fields
Case — Priority, OwnerId, CaseNumber
Task — WhatId, OwnerId, Subject, ActivityDate, Status, Priority
Approach
- 1Loop Trigger.new; check c.Priority == 'High' and c.OwnerId != null.
- 2Build each Task with WhatId = c.Id, OwnerId = c.OwnerId, and a Subject referencing c.CaseNumber.
- 3Insert the full Task list once, after the loop.
- 4Test: a High priority Case (creates 1 Task), a Medium priority Case (creates 0), and a bulk mix.
Medium
TriggersRollup
49. Cascade Account Status Change to Related Contacts
Problem #372 · Salesforce Apex Coding Challenge
Problem Statement
Write an after update trigger on the standard object Account
that cascades a custom field Account_Status__c to all related Contact
records whenever it changes on the Account.
Best Practices
- Only recalculate for Accounts whose
Account_Status__c actually changed
(compare against Trigger.oldMap) — don't cascade on every unrelated edit.
- Query related Contacts with a single SOQL call, update once.
Objects / Fields
Account — Account_Status__c (custom Text/Picklist)
Contact — AccountId, Account_Status__c (custom)
Approach
- 1Build a Set of Account Ids where acc.Account_Status__c != oldMap.get(acc.Id).Account_Status__c.
- 2Also build a Map of the new status keyed by AccountId for the second pass.
- 3Query [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds], set each Contact's Account_Status__c from the map, then update once.
- 4Test: change the status on an Account with 2 Contacts and assert both Contacts are updated.
Medium
TriggersValidation
50. Block Contact Deletion When Linked to an Open Opportunity
Problem #373 · Salesforce Apex Coding Challenge
Problem Statement
Write a before delete trigger on the standard object Contact
that blocks deletion if the Contact is linked to any Opportunity (via
OpportunityContactRole) whose StageName is not 'Closed Lost'.
Best Practices
- Use the standard
OpportunityContactRole junction — Contact has no direct
Opportunity lookup out of the box.
- One SOQL query for all Contacts being deleted, not per record.
Objects / Fields
Contact, OpportunityContactRole.ContactId, Opportunity.StageName
Approach
- 1Collect Contact Ids from Trigger.old (or the oldList passed to the handler).
- 2Query [SELECT ContactId FROM OpportunityContactRole WHERE ContactId IN :contactIds AND Opportunity.StageName != 'Closed Lost'].
- 3Collect the matching ContactIds into a Set, then loop the old Contacts and addError() on any match.
- 4Test: a Contact tied to an open Opportunity (blocked) and one tied only to a Closed Lost Opportunity (allowed).
Medium
TriggersAutomation
51. Update Last Activity Date on Account/Contact From Task
Problem #374 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert trigger on the standard object Task
that updates a custom field Last_Activity_Date__c on the related
Account (via WhatId) or Contact (via WhoId)
to today's date.
Best Practices
WhatId/WhoId are polymorphic — use getSObjectType()
to check which object each Id belongs to before querying/updating.
- Combine Account and Contact updates into a single mixed
List<SObject>
so one update call handles both.
Objects / Fields
Task — WhatId, WhoId
Account.Last_Activity_Date__c, Contact.Last_Activity_Date__c (both custom Date)
Approach
- 1Loop the new Tasks; if t.WhatId != null and its type is Account, put Date.today() into an accountUpdates map keyed by Id.
- 2Do the same for t.WhoId when its type is Contact.
- 3Build one List combining `new Account(Id=.., Last_Activity_Date__c=..)` and `new Contact(Id=.., Last_Activity_Date__c=..)` entries.
- 4Test: a Task with WhatId on an Account, and a separate Task with WhoId on a Contact.
Medium
TriggersValidation
52. Block Project Completion While Child Tasks Are Open
Problem #375 · Salesforce Apex Coding Challenge
Problem Statement
Write a before update trigger on the custom object Project__c
that prevents changing Status__c to "Completed" if there are related
Task__c records still marked "Open".
Best Practices
- Only check Projects actually transitioning into Completed (compare against
oldMap).
- One SOQL query for all such Projects, not per record.
Objects / Fields
Project__c — Status__c
Task__c — Project__c (lookup), Status__c
Approach
- 1Collect a Set of Projects where Status__c is becoming Completed (new == Completed, old != Completed).
- 2Query [SELECT Project__c FROM Task__c WHERE Project__c IN :becomingComplete AND Status__c = 'Open'].
- 3Collect the matching Project Ids into a Set, then loop newList and addError() on any match.
- 4Test: a Project with an open Task (blocked) and the same Project after the Task is closed (allowed).
Medium
TriggersValidation
53. Validate Timesheet Weekly Hours Do Not Exceed 40
Problem #376 · Salesforce Apex Coding Challenge
Problem Statement
Write a before insert trigger on the custom object Timesheet__c
that prevents saving a new Timesheet if the employee's total hours for that week (existing
records + the new batch) would exceed 40.
Best Practices
- Use an aggregate SOQL grouped by Employee and week to get existing totals in one query.
- Track a running total per employee/week key so multiple new Timesheet rows for the
same employee/week arriving in the same bulk batch are correctly summed together.
Objects / Fields
Timesheet__c — Employee__c (lookup), Week_Start_Date__c (Date), Hours__c (Number)
Approach
- 1Query [SELECT Employee__c empId, Week_Start_Date__c weekStart, SUM(Hours__c) totalHours FROM Timesheet__c WHERE Employee__c IN :employeeIds GROUP BY Employee__c, Week_Start_Date__c] once.
- 2Build a key like employeeId + '_' + weekStart to index both the existing totals map and a running-total map for the current batch.
- 3For each new Timesheet, add its Hours__c to the running total for its key; if the result exceeds 40, addError().
- 4Test: a single new Timesheet that alone exceeds 40 (blocked), and two smaller new Timesheets in the same batch for the same employee/week whose combined total exceeds 40 (also blocked).
Medium
TriggersValidation
54. Block Vendor Deletion While Active Purchase Orders Exist
Problem #377 · Salesforce Apex Coding Challenge
Problem Statement
Write a before delete trigger on the custom object Vendor__c
that prevents deletion if there are related Purchase_Order__c records with
Status__c = 'Active'.
Best Practices
- One SOQL query for all Vendors being deleted — never per record.
Objects / Fields
Vendor__c, Purchase_Order__c.Vendor__c (lookup), Purchase_Order__c.Status__c
Approach
- 1Collect Vendor Ids from the oldList passed to the handler.
- 2Query [SELECT Vendor__c FROM Purchase_Order__c WHERE Vendor__c IN :vendorIds AND Status__c = 'Active'].
- 3Collect the matching Vendor Ids into a Set, then loop oldVendors and addError() on any match.
- 4Test: a Vendor with an Active PO (blocked) and one with only Closed POs (allowed).
Hard
TriggersRollup
55. Roll Up Open Opportunity Amount Onto the Account
Problem #378 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert, after update trigger on the standard object
Opportunity that rolls up the total Amount of all
open (IsClosed = false) Opportunities onto a custom field
Open_Opportunity_Total__c on the parent Account.
Best Practices
- Only recalculate Accounts whose Opportunities actually changed
Amount or
StageName — not on every unrelated field edit.
- Use an aggregate
SUM(...) query grouped by AccountId — one query regardless
of batch size.
- Dedup target Accounts with a
Set<Id> so each Account is updated exactly once.
Objects / Fields
Opportunity — AccountId, Amount, StageName, IsClosed
Account.Open_Opportunity_Total__c (custom Currency)
Approach
- 1On insert there is no oldMap — always recalc. On update, only recalc if Amount or StageName changed.
- 2Collect the affected AccountIds into a Set.
- 3Query [SELECT AccountId accId, SUM(Amount) total FROM Opportunity WHERE AccountId IN :accountIds AND IsClosed = false GROUP BY AccountId].
- 4Build one Account update per Id in the set (default to 0 if the map has no entry for it), then update once.
Hard
TriggersAutomation
56. Auto-Escalate Cases Older Than 5 Days
Problem #379 · Salesforce Apex Coding Challenge
Problem Statement
Write a before update trigger on the standard object Case that
sets Status to "Escalated" whenever a Case is not already
Closed or Escalated, and its CreatedDate is more than
5 days old.
Important Caveat
A trigger only fires on DML. A Case nobody touches for 5 days will never
re-enter this trigger on its own. In production, pair this logic with a nightly
Scheduled/Batch Apex job that queries stale open Cases and updates them
(which then safely re-fires this same trigger). This exercise focuses on the escalation
logic itself, assuming the Case is being updated.
Objects / Fields
Case — Status, CreatedDate (standard)
Approach
- 1Use Test.setCreatedDate() in your test to backdate a Case's CreatedDate for a deterministic test.
- 2Compute age with c.CreatedDate.date().daysBetween(Date.today()) > 5.
- 3Skip Cases already Status == 'Closed' or Status == 'Escalated'.
- 4Set c.Status = 'Escalated' directly — no addError needed, this is a corrective update, not a validation.
Hard
TriggersRollup
57. Roll Up Expense Amount to Parent Project (Full DML Coverage)
Problem #380 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert, after update, after delete trigger on the custom
object Expense__c that rolls up the total Amount__c of all child
Expense records onto a custom field Total_Expense__c on the parent
Project__c.
Best Practices
- A rollup that only handles insert/update will drift out of sync the moment someone
deletes an Expense — cover all three DML contexts.
- Use one aggregate
SUM(...) query per context, grouped by Project__c.
Objects / Fields
Expense__c — Project__c (lookup), Amount__c (Currency)
Project__c.Total_Expense__c (custom Currency)
Approach
- 1Write a shared private helper rollupToProjects(Set projectIds) used by all three handler methods.
- 2For insert/update, collect Project__c ids from the new records. For delete, collect them from the old records.
- 3Query [SELECT Project__c projId, SUM(Amount__c) total FROM Expense__c WHERE Project__c IN :projectIds GROUP BY Project__c].
- 4Build one Project__c update per Id in projectIds, defaulting Total_Expense__c to 0 if the map has no entry — this correctly zeroes out a Project whose last Expense was just deleted.
Hard
TriggersConcurrency
58. Decrement Product Stock When an Order Item Is Created
Problem #381 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert trigger on the custom object Order_Item__c
that decrements Quantity_In_Stock__c on the related Product__c,
blocking the Order Item with addError() if there isn't enough stock.
Best Practices
- Query the related Products with
FOR UPDATE to lock those rows for the
duration of the transaction, preventing two simultaneous orders from over-selling the same stock.
- Bulk-safe: one query for all Products referenced in the batch.
Objects / Fields
Order_Item__c — Product__c (lookup), Quantity__c (Number)
Product__c.Quantity_In_Stock__c (Number)
Approach
- 1Collect Product Ids from the Order Items into a Set.
- 2Query [SELECT Id, Quantity_In_Stock__c FROM Product__c WHERE Id IN :productIds FOR UPDATE] into a Map.
- 3For each Order Item, compute current stock minus Quantity__c; if negative, addError() on the Order Item, otherwise update the in-memory Product.
- 4Update the Product map's values once after the loop.
Hard
TriggersAsync
59. Email the Owner When a Contract Expires
Problem #382 · Salesforce Apex Coding Challenge
Problem Statement
Write an after update trigger on the custom object Contract__c
that sends an email alert to the record Owner whenever
Contract_Status__c transitions to "Expired".
Best Practices
- Email sending (like any slow, callout-adjacent operation) should not run synchronously
inside a trigger's transaction — dispatch it via an
@future method.
- Only enqueue the future call for Contracts that actually transitioned into Expired.
Objects / Fields
Contract__c — Contract_Status__c, OwnerId (standard)
Approach
- 1Compare ct.Contract_Status__c to oldMap.get(ct.Id).Contract_Status__c to detect the transition into Expired.
- 2Collect qualifying OwnerIds into a Set and pass that Set into an @future method — @future methods only accept primitive/collection-of-primitive parameters.
- 3Inside the future method, query User emails and send one Messaging.SingleEmailMessage per owner.
- 4In your test, use Test.startTest()/Test.stopTest() and assert Limits.getFutureCalls() to verify the async call was (or was not) enqueued — that is a reliable, deterministic assertion for future methods.
Hard
TriggersAutomation
60. Round-Robin Assign New Tickets to Support Agents
Problem #383 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert trigger on the custom object Ticket__c
that assigns each new Ticket's owner using round-robin logic across active support agents
for its Category__c.
Supporting Objects
This design needs two small helper custom objects to make the round-robin state durable
across transactions:
Support_Agent__c — User__c (lookup to User), Category__c, Active__c (Checkbox)
Round_Robin_Tracker__c — Category__c, Last_Index__c (Number) — stores the last-assigned agent index per category
Best Practices
- Query the
Round_Robin_Tracker__c row with FOR UPDATE so two
simultaneous Ticket inserts can't both land on the same agent.
upsert the tracker so a category with no prior tracker still gets one created.
Objects / Fields
Ticket__c — Category__c, OwnerId
Approach
- 1Build a Map> of active agent User Ids per category, ordered consistently (e.g. ORDER BY Name).
- 2Query existing Round_Robin_Tracker__c rows FOR UPDATE, keyed by Category__c.
- 3For each new Ticket, compute nextIndex = Math.mod(lastIndex + 1, agents.size()); assign OwnerId = agents[nextIndex]; update lastIndex for that category.
- 4upsert the tracker records once after the loop.
Hard
TriggersRollup
61. Roll Up Average Feedback Rating Onto the Product (Bulk-Safe)
Problem #384 · Salesforce Apex Coding Challenge
Problem Statement
Write an after insert trigger on the custom object Feedback__c
that calculates the average rating and updates a custom field Average_Rating__c
on the related custom object Product__c, handling bulk (200+ record) inserts
efficiently without hitting governor limits.
Best Practices
- Use an aggregate
AVG(...) query grouped by Product__c — this
computes every Product's average in one SOQL call regardless of batch size.
- Dedup target Products with a
Set<Id> so each Product is updated exactly
once even if dozens of Feedback records in the batch point to the same Product.
Objects / Fields
Feedback__c — Product__c (lookup), Rating__c (Number)
Product__c.Average_Rating__c (custom Number)
Approach
- 1Collect Product Ids from the new Feedback records into a Set.
- 2Query [SELECT Product__c prodId, AVG(Rating__c) avgRating FROM Feedback__c WHERE Product__c IN :productIds GROUP BY Product__c].
- 3Build one Product__c update per Id in the set, using totals.get(id).setScale(2) — default to 0 if there is no entry.
- 4Test with a mix of Products and multiple Feedback records each, to prove the aggregate approach scales without extra queries.
About ApexArena
ApexArena is a free, browser-based Salesforce Apex coding practice platform covering every major topic tested on the Salesforce Platform Developer I (PD1) and Platform Developer II (PD2) certification exams. All problems run directly in your browser with instant pass/fail feedback, best-practice linting (SOQL in loops, DML in loops, empty catch blocks), and governor limit monitoring — no Salesforce Developer Edition org required.
Related tutorials:
Apex Triggers ·
SOQL ·
Batch Apex ·
Interview Q&A ·
Governor Limits