Apex Trigger

Medium & Hard Apex Trigger Practice Problems

79 free Salesforce Apex challenges · Full problem statements, best-practice notes, and step-by-step hints · Solve live on ApexArena

This guide walks through 79 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.

On this page
  1. 1. Contact Count Rollup Trigger
  2. 2. Recursive Trigger Guard
  3. 3. Contact Email Deduplication
  4. 4. Opportunity Won Revenue Rollup
  5. 5. Prevent Duplicate Contacts by Email
  6. 6. Auto-Close Stale Opportunities
  7. 7. Case Count Rollup on Account
  8. 8. Dynamic Required Field Enforcement
  9. 9. Sync Account Phone to Child Contacts
  10. 10. Stage Change Close Date Setter
  11. 11. Account Deletion Blocker
  12. 12. Opportunity Undelete Restorer
  13. 13. Deactivate Account Validation Trigger
  14. 14. Employee Termination Validation Trigger
  15. 15. Cross-Object IsActive Checkbox Sync
  16. 16. Country-wise Account Count Tracker
  17. 17. Total Contact Count Rollup on Account
  18. 18. Sync Email Field Across Sibling Opportunities
  19. 19. Auto-Reassign Accounts & Opportunities on User Deactivation
  20. 20. Trigger: Set Closed-Won Date on Opportunity Stage Change
  21. 21. Trigger: Rollup Contact Count onto Account
  22. 22. Trigger: Auto-Create Follow-Up Task for High Priority Cases
  23. 23. Trigger: Auto-Calculate Lead Score
  24. 24. Trigger: Block Opportunity Stage Regression
  25. 25. Trigger: Prevent Duplicate Contact Email
  26. 26. Trigger + Handler: Update Campaign Stats on Closed Won
  27. 27. Trigger + Handler: Escalate Case Priority on SLA Breach
  28. 28. Trigger + Handler: Auto-Assign Case Owner by Support Region
  29. 29. Trigger + Handler: Block Account Deletion When Open Opportunities Exist
  30. 30. Trigger + Handler: Rollup Closed-Won Revenue onto Account
  31. 31. Apex Trigger: Auto-Set Opportunity Probability on Stage Change
  32. 32. Trigger + Handler: Prevent Duplicate Lead Email on Insert and Update
  33. 33. Apex Trigger: Auto-Populate Contact Description from Account on Insert
  34. 34. Trigger + Handler: Auto-Format Contact Phone on Insert/Update
  35. 35. Trigger + Handler: Block Opportunity Close When Amount Is Null
  36. 36. Trigger + Handler: Rollup Open Opportunity Count onto Account
  37. 37. Trigger + Handler: Block Contact Deletion When Linked to Closed Won Opportunity
  38. 38. Trigger: Maintain Contact Count Rollup on Account
  39. 39. Trigger: Enforce Unique Email Across All Leads
  40. 40. Trigger: Cascade Account Billing Address to Related Contacts
  41. 41. Trigger: Rollup Sum from Child Custom Object to Parent
  42. 42. Trigger: Flag Out_of_Zip__c When Contact Postal Code Differs
  43. 43. Trigger: Auto-Create Parent Account for Orphaned Contacts
  44. 44. Trigger: Flag needintel__c When 70%+ Contacts Are Inactive
  45. 45. Trigger: Update Contact Profile URL When Account Website Changes
  46. 46. Trigger: Flag is_gold__c on Accounts with Opportunities Over $20,000
  47. 47. Copy Account Billing Address to New Contact
  48. 48. Auto-Create Follow-Up Task for High-Priority Cases
  49. 49. Cascade Account Status Change to Related Contacts
  50. 50. Block Contact Deletion When Linked to an Open Opportunity
  51. 51. Update Last Activity Date on Account/Contact From Task
  52. 52. Block Project Completion While Child Tasks Are Open
  53. 53. Validate Timesheet Weekly Hours Do Not Exceed 40
  54. 54. Block Vendor Deletion While Active Purchase Orders Exist
  55. 55. Roll Up Open Opportunity Amount Onto the Account
  56. 56. Auto-Escalate Cases Older Than 5 Days
  57. 57. Roll Up Expense Amount to Parent Project (Full DML Coverage)
  58. 58. Decrement Product Stock When an Order Item Is Created
  59. 59. Email the Owner When a Contract Expires
  60. 60. Round-Robin Assign New Tickets to Support Agents
  61. 61. Roll Up Average Feedback Rating Onto the Product (Bulk-Safe)
  62. 62. Prevent Account Owner Change When Pending Approval Exists
  63. 63. Synchronize Opportunity Type With Account Customer Segment
  64. 64. Update Account Health Status From Related Cases
  65. 65. Copy Opportunity Primary Contact Phone to Custom Field
  66. 66. Automatically Flag Accounts With Expiring Contracts
  67. 67. Prevent Multiple Primary Contacts Per Account
  68. 68. Scenario: Prevent Duplicate Support Cases from the Same Contact Within One Hour
  69. 69. Scenario: Track the Highest-Value Open Opportunity Per Account in Real Time
  70. 70. Scenario: Notify the Account Owner When a Case Reaches Critical Priority
  71. 71. Scenario: Flag Potential Duplicate Opportunities on the Same Account and Close Month
  72. 72. Create a Case When a High-Value Opportunity Is Lost
  73. 73. Prevent Duplicate Product Entries in an Opportunity
  74. 74. Update Contact Loyalty Status Based on Total Purchases
  75. 75. Create Renewal Opportunities Automatically
  76. 76. Prevent Changing Account Owner for VIP Customers
  77. 77. Update Parent Account Risk Level Based on Open Cases
  78. 78. Prevent Opportunity Closure If Mandatory Documents Are Missing
  79. 79. Synchronize Custom Object Records Automatically
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

  • AccountIsActive__c (Checkbox custom field)
  • OpportunityAccountId, StageName
Approach
  • 1In the handler, build a Set of Account Ids being deactivated (IsActive__c changed from true → false).
  • 2Query Opportunities belonging to those accounts whose StageName is neither Closed Won nor 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__cStatus__c (picklist: Active, On Leave, Terminated)
  • Employee_Review__cEmployee__c (Lookup to Employee__c)
Approach
  • 1Collect Ids where Status__c is changing to "Terminated".
  • 2Query Employee_Review__c once for all terminating Ids, grouping by the Employee__c lookup so you can count reviews per employee.
  • 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: find those belonging to the deactivated accounts that are still currently active.
  • 3One query for Users: find the owner Users of those accounts that are still currently active.
  • 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 a single aggregate query on Account that counts records per BillingCountry, restricted to the affected countries.
  • 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 query grouped by Account to get each Account's live Contact count in a single round-trip.
  • 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 the sibling Opportunities on those same accounts, excluding the opportunities that triggered the change.
  • 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 currently owned by any of the deactivated users.
  • 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, grouped by Account, to get each affected Account's live Contact count
  • 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.
  • Read the per-Account count off the AggregateResult and cast it to Integer.
  • 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 grouped by AccountId.
  • 2An AggregateResult row exposes your COUNT() alias via get('yourAlias') — cast it to Integer.
  • 3Map each AccountId to its count, then set acc.Contact_Count__c from that map when building the update list.
  • 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:

FieldConditionPoints
LeadSourceWeb+10
LeadSourcePhone Inquiry+8
LeadSourceOther+5
TitleContains 'VP' or 'Director'+15
TitleContains 'Manager'+8
CompanyNot blank+5
EmailNot 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

  1. Collect non-blank emails from Trigger.new.
  2. Run one SOQL: existing contacts with matching email, excluding records being updated (Id NOT IN :Trigger.newMap.keySet()).
  3. Build a Set<String> of existing emails (lowercased).
  4. 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.
  • 2Query existing Contacts whose Email matches one of the incoming emails, then exclude the records currently being updated from that result.
  • 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

  1. Find opps where StageName changed to 'Closed Won' and CampaignId != null.
  2. Collect unique CampaignIds.
  3. Query the affected Campaigns once, by their Ids, to read each one's current counter.
  4. 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'
  • 2Query the Campaign fields you need to read and update, filtered to the collected 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

  1. Collect unique Region__c values from Trigger.new.
  2. Query the Group object for queue records whose name matches one of the collected regions.
  3. Build a Map<String, Id> of region name → queue Id.
  4. 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 — query them by their queue-type record designation and match their Name against the set of collected 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

  1. Collect all Account Ids from Trigger.old.
  2. Query for open (non-closed) Opportunities linked to those Accounts in a single SOQL call.
  3. Build a Map<Id, List<Opportunity>> grouped by AccountId.
  4. 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 Opportunity records linked to the collected Account Ids, filtered to only the ones still open.
  • 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

  1. Trigger collects affected Account Ids (from Trigger.new AND Trigger.old for updates, Trigger.old for deletes).
  2. Handler uses one AggregateResult SOQL with SUM(Amount) GROUP BY AccountId.
  3. 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.
  • 2An AggregateResult query grouped by AccountId, filtered to closed-won opportunities, gets you each Account's total in one round-trip.
  • 3Read aggregate value: (Decimal) ar.get('yourAliasName')
  • 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

StageProbability
Prospecting10
Qualification20
Needs Analysis30
Value Proposition40
Id. Decision Makers50
Perception Analysis60
Proposal/Price Quote70
Negotiation/Review80
Closed Won100
Closed Lost0

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

  1. Skip blank emails and unchanged emails on update.
  2. Query existing unconverted Leads with matching emails, excluding the records being updated (Id NOT IN :currentIds).
  3. 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: build a Map from a single query of the collected Account Ids (Id and Description fields) so each Contact can look up its parent Account in O(1).
  • 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 eventif (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.
  • 2Run one aggregate query on Contact that counts related records per Account for the whole batch of accountIds.
  • 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 existing Leads whose Email matches anything in the incoming batch's normalised email set, in one bulk call.
  • 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.

Halfway there — solve them live

Every problem above runs in a real in-browser Apex editor with instant pass/fail feedback and best-practice linting. No Salesforce org needed.

Create Free Account →
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.
  • 2Run a single aggregate query on CustomObjectB__c that sums Value__c per parent Id for the whole batch of parentIds.
  • 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 the related Contacts for the batch of changedIds in one call, traversing the parent-to-child relationship to also read the Account's (new) BillingPostalCode alongside each Contact's MailingPostalCode.
  • 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.
  • Work out each Account's dead-Contact percentage from those two counts, guarding against division by zero.
  • 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
  • 1Run an aggregate query on Contact that counts every related record per Account for the whole batch of accIds — this gives you the total count.
  • 2Run a second aggregate query the same way, this time counting only the Contacts on each Account where Dead__c is true.
  • 3Guard against dividing by zero, then work out each Account's dead percentage with integer-safe math and flag it once that percentage reaches the 70% threshold from the requirements.
  • 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 SmithProfile__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
  • 1Compare each Account's new Website to its prior value from oldMap, and only treat it as changed when the new value is also non-null.
  • 2The SOQL already retrieves Account.Website — use con.Account.Website in the loop.
  • 3Build the Profile__c value by combining the Account's Website, a "/", the Contact's first-name initial, and their LastName — matching the format shown in the problem statement.
  • 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 the Opportunities for the batch of accountIds, filtered down to just the high-value ones from the requirements, and pull back only the AccountId.
  • 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

  • ContactAccountId, MailingStreet/City/State/PostalCode/Country
  • AccountBillingStreet/City/State/PostalCode/Country
Approach
  • 1Only collect AccountIds for Contacts where MailingStreet AND MailingCity are both null.
  • 2Query the related Accounts (with their Billing address fields) in a single batched call keyed by Account Id.
  • 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

  • CasePriority, OwnerId, CaseNumber
  • TaskWhatId, 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

  • AccountAccount_Status__c (custom Text/Picklist)
  • ContactAccountId, 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 the related Contacts for those changed Accounts in one call, 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 OpportunityContactRole for those Contact Ids, filtering to roles whose related Opportunity is not in a closed-lost stage.
  • 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

  • TaskWhatId, 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__cStatus__c
  • Task__cProject__c (lookup), Status__c
Approach
  • 1Collect a Set of Projects where Status__c is becoming Completed (new == Completed, old != Completed).
  • 2Query the child Task__c records for those transitioning Projects, filtered down to the ones still marked 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__cEmployee__c (lookup), Week_Start_Date__c (Date), Hours__c (Number)
Approach
  • 1Run a single aggregate query that sums Hours__c for the relevant employees, grouped by employee and week start date.
  • 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 Purchase_Order__c for those Vendor Ids, filtered down to the Active-status records only.
  • 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

  • OpportunityAccountId, 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.
  • 3Run a single aggregate query summing Amount for open Opportunities on those Accounts, grouped 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

  • CaseStatus, 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__cProject__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.
  • 3Run one aggregate query summing Amount__c for those Projects, grouped 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__cProduct__c (lookup), Quantity__c (Number)
  • Product__c.Quantity_In_Stock__c (Number)
Approach
  • 1Collect Product Ids from the Order Items into a Set.
  • 2Query the referenced Products (locking the rows for update) keyed by Id 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__cContract_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__cUser__c (lookup to User), Category__c, Active__c (Checkbox)
  • Round_Robin_Tracker__cCategory__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__cCategory__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__cProduct__c (lookup), Rating__c (Number)
  • Product__c.Average_Rating__c (custom Number)
Approach
  • 1Collect Product Ids from the new Feedback records into a Set.
  • 2Run a single aggregate query averaging Rating__c for those Products, grouped 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.
Medium Triggers

62. Prevent Account Owner Change When Pending Approval Exists

Problem #544 · Salesforce Apex Coding Challenge

Problem Statement

Write a trigger PreventOwnerChangeDuringApproval on Account that fires before update.

If an Account's OwnerId is being changed while that Account has a pending approval request (a ProcessInstance with Status = 'Pending'), block the change with addError().

Requirements

  • Only check Accounts whose OwnerId is actually changing.
  • Use a single bulk SOQL query against ProcessInstance, outside any loop.
  • Bulk-safe: handle multiple Account updates in one transaction.
Approach
  • 1Compare acc.OwnerId against Trigger.oldMap.get(acc.Id).OwnerId to detect an owner change.
  • 2Query ProcessInstance with WHERE TargetObjectId IN :accountIds AND Status = 'Pending' — one bulk call, not per record.
  • 3Collect matching Account Ids into a Set, then loop Trigger.new once more to call addError().
Medium Triggers

63. Synchronize Opportunity Type With Account Customer Segment

Problem #547 · Salesforce Apex Coding Challenge

Problem Statement

Write a trigger SyncOpportunityTypeWithSegment on Opportunity that fires on before insert and before update.

Automatically set the Opportunity's Type based on the parent Account's Customer_Segment__c:

  • EnterpriseNew Customer
  • Mid-MarketExisting Customer - Upgrade
  • Small BusinessExisting Customer - Replacement

Requirements

  • Use a single bulk SOQL query against Account, outside any loop.
  • Leave Type untouched if the segment doesn't match a known mapping.
Approach
  • 1A Map<Id, Account> built directly from a SOQL query gives you Id-keyed lookups for free.
  • 2A Map<String, String> literal is a clean way to express the segment → Type mapping.
  • 3Skip setting Type entirely when the segment isn't in the map, so an unrelated value is never overwritten with something wrong.
Hard Triggers

64. Update Account Health Status From Related Cases

Problem #550 · Salesforce Apex Coding Challenge

Problem Statement

Write a trigger UpdateAccountHealthFromCases on Case that keeps each related Account's Health_Status__c in sync with its open, high-priority Case load.

Whenever an Account has 3 or more open (IsClosed = false) Cases with Priority = 'High', set Health_Status__c to 'Critical'; otherwise set it to 'Healthy'.

Requirements

  • Fire on after insert, after update, after delete, and after undelete — any Case change can shift the count.
  • Recompute using a single aggregate SOQL query (GROUP BY AccountId), not a loop.
  • Bulk-safe: recompute every affected Account in one query and one DML update.
Approach
  • 1Trigger.isDelete means the records live in Trigger.old, not Trigger.new.
  • 2An aggregate query like SELECT AccountId, COUNT(Id) cnt FROM Case WHERE ... GROUP BY AccountId gives you a count per Account in one call.
  • 3Accounts with zero matching Cases won't appear in the aggregate result at all — default their count to 0 rather than skipping them.
Medium Triggers

65. Copy Opportunity Primary Contact Phone to Custom Field

Problem #551 · Salesforce Apex Coding Challenge

Problem Statement

Write a trigger SyncPrimaryContactPhone on Opportunity that fires on before insert and before update.

Whenever the Opportunity's Primary_Contact__c lookup is set or changed, copy that Contact's Phone into Primary_Contact_Phone__c. If Primary_Contact__c is cleared, clear the phone field too.

Requirements

  • Use a single bulk SOQL query against Contact, outside any loop.
  • On update, only re-query Contacts whose Primary_Contact__c actually changed.
Approach
  • 1On insert there's no Trigger.oldMap — guard the "did it change" check with Trigger.isInsert || first so it short-circuits before touching oldMap.
  • 2A Map<Id, Contact> built directly from the SOQL query gives you a Phone lookup by Contact Id.
Medium Triggers

66. Automatically Flag Accounts With Expiring Contracts

Problem #552 · Salesforce Apex Coding Challenge

Problem Statement

Write a trigger FlagAccountsWithExpiringContracts on Contract that fires on after insert and after update.

Set the parent Account's Contract_Expiring__c checkbox to true when any of its Activated Contracts has an EndDate within the next 30 days (inclusive); otherwise set it to false.

Requirements

  • Use a single bulk SOQL query against Contract, outside any loop.
  • Only consider Contracts with Status = 'Activated'.

A Note on Time-Based Logic

Because "within 30 days" changes as time passes with no record edit at all, a trigger alone can only re-evaluate an Account when one of its Contracts is actually written to. A production implementation would pair this trigger with a nightly Schedulable/Batch job to catch a Contract simply aging into the 30-day window.

Approach
  • 1Date.today().addDays(30) gives you the cutoff date.
  • 2Filter with EndDate >= TODAY AND EndDate <= :cutoff so past-dated Contracts don't count.
  • 3Every affected Account needs an explicit true/false — don't skip Accounts with zero expiring Contracts, they need to be reset to false too.
Hard Triggers

67. Prevent Multiple Primary Contacts Per Account

Problem #553 · Salesforce Apex Coding Challenge

Problem Statement

Write a trigger PreventMultiplePrimaryContacts on Contact that fires on before insert and before update.

An Account may have at most one Contact with Is_Primary_Contact__c = true. Block, with addError(), any Contact save that would give an Account a second primary Contact.

Requirements

  • Use a single bulk SOQL query against Contact to find each Account's existing primary Contact, outside any loop.
  • Bulk-safe: two new Contacts both claiming primary for the same Account in the same transaction must not both be allowed through.
  • Updating the Account's existing primary Contact (keeping it primary) must not conflict with itself.
Approach
  • 1On insert, con.Id is null — comparing it to an existing primary's Id correctly identifies "some other, already-saved record already holds this".
  • 2Track Account Ids "claimed" earlier in the same loop with a Set<Id> so two new Contacts in one insert batch can't both slip through.
Medium Triggers

68. Scenario: Prevent Duplicate Support Cases from the Same Contact Within One Hour

Problem #439 · Salesforce Apex Coding Challenge

Business Scenario

Frustrated customers sometimes submit the same issue multiple times in quick succession through different channels, creating duplicate Cases. Block a new Case from being created if the same Contact already has a Case created within the last 60 minutes.

Requirements

  • Collect all Contact Ids from the inserted Cases first, then run one aggregate query — never query per record.
  • Count each Contact's recent Cases created within the last 60 minutes, grouped per Contact.
  • Block (via addError) any new Case whose Contact already has a recent Case.

Best Practices

  • Bulkify by collecting all Contact Ids into a Set<Id> first, then running a single GROUP BY aggregate query — this scales to a bulk API insert of hundreds of Cases in one query.
  • addError() on the new record is the standard way to block a save from a before insert trigger.
Approach
  • 1Collect ContactIds into a Set in one pass over Trigger.new, skipping any null ContactId.
  • 2DateTime.now().addMinutes(-60) gives the cutoff for "within the last hour".
  • 3A single aggregate query, grouped by ContactId, can count every Contact's recent Cases at once — no need to query per Contact.
  • 4c.addError('...') on the new Case blocks the insert with a message the user sees.
Medium Triggers

69. Scenario: Track the Highest-Value Open Opportunity Per Account in Real Time

Problem #440 · Salesforce Apex Coding Challenge

Business Scenario

Sales leadership wants each Account's custom field Top_Open_Opportunity_Amount__c to always reflect the largest still-open Opportunity on that Account, updated the moment any Opportunity changes — insert, update, delete, or undelete.

Requirements

  • Handle all four trigger contexts: insert, update, delete, undelete.
  • Recompute each affected Account's largest still-open Opportunity amount with a single aggregate query covering the whole batch — never one query per Account.
  • An Account with no open Opportunities left should reset to 0, not stay stale.

Best Practices

  • This must be an after trigger since it re-queries the Opportunity table including the just-changed records.
  • Trigger.old is used for the delete context (there is no Trigger.new on delete); every other context uses Trigger.new.
  • Recompute from scratch via MAX() rather than comparing only the changed record's Amount — that's the only way a delete of the current top Opportunity correctly falls back to the next-highest one.
Approach
  • 1Trigger.isDelete tells you to read from Trigger.old instead of Trigger.new — there is no Trigger.new on a delete.
  • 2A single aggregate query, filtered to open Opportunities and grouped per Account, can find every affected Account's new top amount at once.
  • 3Default to 0 for any Account not present in the aggregate result — that means it has no open Opportunities left.
  • 4This must be an after trigger, not before — it needs to see the fully-committed state of the just-changed Opportunity to query against it.
Medium Triggers

70. Scenario: Notify the Account Owner When a Case Reaches Critical Priority

Problem #443 · Salesforce Apex Coding Challenge

Business Scenario

When a Case's Priority becomes "Critical" — either set that way on creation, or changed to it later — create a follow-up Task on the related Account so the account team notices immediately.

Requirements

  • Fires on insert (created already Critical) and on update (changed to Critical).
  • Must not re-fire on every subsequent edit of an already-Critical Case — only the transition into Critical creates a new Task.
  • Skip Cases with no related Account.

Best Practices

  • Comparing against Trigger.oldMap is how you detect a genuine transition into a value, rather than firing on every save where the value happens to already be Critical.
  • Collect every Task into one list and insert once, outside the loop.
Approach
  • 1Trigger.isInsert is true for newly-created Cases that are already Critical.
  • 2On update, Trigger.oldMap.get(c.Id).Priority lets you check the PRIOR value — only a change away from Critical-already-set should count as a new transition.
  • 3Skip AccountId == null Cases — there's no Account to attach the Task to.
  • 4Collect all Tasks into one List and call insert once outside the loop.
Hard Triggers

71. Scenario: Flag Potential Duplicate Opportunities on the Same Account and Close Month

Problem #447 · Salesforce Apex Coding Challenge

Business Scenario

Reps occasionally create a second Opportunity for the same deal instead of updating the existing one. Flag a new Opportunity as a potential duplicate when the same Account already has another open Opportunity closing in the same month and year.

Requirements

  • Collect Account Ids from the new Opportunities first, then run a single aggregate query grouped by Account, close month, and close year.
  • Set Potential_Duplicate__c = true on any new Opportunity whose Account+month+year combination already has an existing open Opportunity.
  • Skip Opportunities missing an Account or a Close Date.

Best Practices

  • CALENDAR_MONTH()/CALENDAR_YEAR() in a GROUP BY aggregate is the standard way to bucket existing records by month without pulling every record into Apex to compare dates manually.
  • Building a composite string key (Account + year + month) lets you do one bulk lookup instead of a nested loop comparing every new Opportunity against every existing one.
Approach
  • 1CALENDAR_MONTH(CloseDate) and CALENDAR_YEAR(CloseDate) can both appear in the same GROUP BY alongside AccountId.
  • 2Build a String key like accountId + '-' + year + '-' + month for both the aggregate results and each new Opportunity, so you can look up matches with a single Map.
  • 3o.CloseDate.year() and o.CloseDate.month() give you the components to build the same key for each new record.
Medium Triggers

72. Create a Case When a High-Value Opportunity Is Lost

Problem #472 · Salesforce Apex Coding Challenge

Business Scenario

Sales leadership wants visibility into every high-value deal that falls through. When an Opportunity with Amount >= 100,000 transitions to Closed Lost, automatically open an internal Case for the team to review what happened.

Requirements

  • After update trigger on Opportunity.
  • Only fire on a genuine transition into Closed Lost (not already Closed Lost), and only when Amount meets the high-value threshold.
  • The Case should reference the Opportunity's Account and summarize the lost deal in its Description.

Best Practices

  • Named constant for the high-value threshold, so the bar for "review-worthy" deals is a one-line change.
  • Collect all qualifying Cases into a list first and insert once — bulk-safe even if many high-value deals are lost in the same batch update.
  • Wrap the insert in try/catch(DmlException).
Approach
  • 1Combine two Booleans — justLost and isHighValue — then only create the Case when both are true.
  • 2AccountId = newOpp.AccountId links the Case straight to the Account for easy triage.
  • 3Guard Amount against null before comparing it to the threshold.
Medium Triggers

73. Prevent Duplicate Product Entries in an Opportunity

Problem #473 · Salesforce Apex Coding Challenge

Business Scenario

Sales operations wants to stop reps from accidentally adding the same Product twice to an Opportunity's line items, which throws off quantity and revenue totals.

Requirements

  • Before insert trigger on OpportunityLineItem.
  • Block a new line item whose Product2Id already exists on the same Opportunity, using addError().
  • Also catch duplicates within the same insert batch — two new line items for the same Opportunity and Product added in one call must not both slip through.

Best Practices

  • Query existing line items for all affected Opportunities in a single SOQL call, then check every new line item against the results in memory — no query per record.
  • A second in-memory Map tracks products already seen earlier in the same Trigger.new batch, since a same-transaction duplicate would not appear in the existing-records query.
Approach
  • 1PricebookEntry.Product2Id (or the OpportunityLineItem.Product2Id shortcut field) identifies the product on a line item.
  • 2You need two duplicate checks: against already-saved records (from the query) and against other new records in the same Trigger.new list.
  • 3A Map> keyed by OpportunityId lets you check "is this product already used on this specific Opportunity" in O(1).
Medium Triggers

74. Update Contact Loyalty Status Based on Total Purchases

Problem #474 · Salesforce Apex Coding Challenge

Business Scenario

Customer Success wants a Contact's loyalty tier kept in sync with their total Closed Won purchase history. Opportunities carry a custom lookup Primary_Contact__c to Contact, and Contact has a custom picklist Loyalty_Status__c (Standard/Silver/Gold/Platinum).

Requirements

  • After update trigger on Opportunity, firing when an Opportunity transitions into Closed Won.
  • Recompute the Contact's total Closed Won Amount (not just this one deal) and set the tier: Platinum ≥ 100,000, Gold ≥ 50,000, Silver ≥ 10,000, else Standard.
  • Only update Contacts whose tier actually changes.

Best Practices

  • Use an aggregate SUM(Amount) ... GROUP BY Primary_Contact__c query to get each Contact's true lifetime total in one query, rather than summing in Apex from a detail query.
  • Compare the computed tier against the Contact's current Loyalty_Status__c before adding it to the update list — skip Contacts whose tier is unchanged to avoid a redundant DML write.
  • Wrap the update in try/catch(DmlException).
Approach
  • 1GROUP BY Primary_Contact__c with SUM(Amount) gives each Contact's true lifetime Closed Won total in one query.
  • 2Use nested ternaries (or if/else if) ordered from the highest tier down to compute the new status string.
  • 3Compare newStatus against the Contact's current Loyalty_Status__c and only add to the update list when they differ.
Medium Triggers

75. Create Renewal Opportunities Automatically

Problem #475 · Salesforce Apex Coding Challenge

Business Scenario

Renewals should never fall through the cracks. When a renewable deal (custom checkbox Is_Renewable__c on Opportunity) closes as Closed Won, a follow-up renewal Opportunity should be created automatically one contract term (12 months) out, so the account team has it on their pipeline well in advance.

Requirements

  • After update trigger on Opportunity.
  • Only act on a transition into Closed Won where Is_Renewable__c is true.
  • The new Opportunity copies the Account and Amount, starts in Prospecting, has a CloseDate 12 months after the original, and tracks its origin via a custom lookup Renewed_From__c.

Best Practices

  • Collect all renewal Opportunities to insert into a list first, then insert once — bulk-safe even if many renewable deals close in the same transaction.
  • Named constant for the renewal term length keeps the "how far out" business rule easy to tune.
  • Wrap the insert in try/catch(DmlException).
Approach
  • 1newOpp.CloseDate.addMonths(RENEWAL_TERM_MONTHS) computes the renewal Opportunity's CloseDate.
  • 2Set Renewed_From__c = newOpp.Id so the new Opportunity traces back to the deal that spawned it.
  • 3Only build a renewal when both the stage transition AND Is_Renewable__c == true are satisfied.
Hard Triggers

76. Prevent Changing Account Owner for VIP Customers

Problem #476 · Salesforce Apex Coding Challenge

Business Scenario

Executive sponsors insist that VIP customer Accounts (custom checkbox VIP_Customer__c) can only have their owner reassigned by specially authorized users, to prevent accidental or unauthorized handoffs of the company's most important relationships.

Requirements

  • Before update trigger on Account.
  • Detect when OwnerId is actually changing on a VIP Account (compare against Trigger.oldMap).
  • Block the change with addError() unless the running user holds a custom permission Reassign_VIP_Accounts.

Best Practices

  • FeatureManagement.checkPermission() checks a Custom Permission on the running user — a declarative, admin-configurable way to gate this override without hardcoding specific User Ids or Profiles in Apex.
  • Use Trigger.oldMap (not a loop-built Map) since before update guarantees it is already populated and index-aligned to Trigger.new by Id.
  • Bulk-safe: the permission is checked once, not per record, and every qualifying record in the batch gets its own addError().
Approach
  • 1Trigger.oldMap.get(newAcc.Id) gives you the pre-update version of the same record to compare OwnerId against.
  • 2FeatureManagement.checkPermission('Reassign_VIP_Accounts') returns a Boolean for the running user's Custom Permission — call it once outside the loop.
  • 3Only records where both VIP_Customer__c is true and OwnerId changed should ever be blocked.
Hard Triggers

77. Update Parent Account Risk Level Based on Open Cases

Problem #477 · Salesforce Apex Coding Challenge

Business Scenario

Support leadership wants an Account's Risk_Level__c (custom picklist: Low/Medium/High) to reflect how many open Cases it currently has, recalculated whenever Cases are inserted, updated, deleted, or undeleted.

Requirements

  • Trigger on Case for after insert, after update, after delete, after undelete.
  • Risk is High with 5+ open Cases, Medium with 2-4, Low otherwise.
  • Recompute using the true current open-Case count from the database (not just the records in the trigger batch), since Cases unrelated to this transaction also affect the count.
  • Only update Accounts whose computed risk level actually changes.

Best Practices

  • Use Trigger.isDelete to pick Trigger.old vs Trigger.new as the source of affected Cases, since Trigger.new is not available in a delete context.
  • A single aggregate query grouped by Account gets the authoritative open-Case count per Account, rather than trying to infer it from just the records in the trigger batch.
  • Compare the newly computed risk level against the Account's current value before adding it to the update list, avoiding a no-op DML write.
Approach
  • 1Trigger.new does not exist in an after delete context — use Trigger.isDelete to choose Trigger.old instead.
  • 2COUNT(Id) ... WHERE IsClosed = false GROUP BY AccountId gives the true current open-Case count regardless of what triggered this run.
  • 3Order your risk-level ternary from High down to Low so the highest-matching tier wins.
Hard Triggers

78. Prevent Opportunity Closure If Mandatory Documents Are Missing

Problem #478 · Salesforce Apex Coding Challenge

Business Scenario

Deal desk requires at least one supporting file (signed contract, approval memo, etc.) to be attached to an Opportunity before it can be closed, whether won or lost, to maintain an audit trail.

Requirements

  • Before update trigger on Opportunity.
  • Detect a transition into Closed Won or Closed Lost from any non-closed stage.
  • Block the transition with addError() if the Opportunity has zero attached Files (query ContentDocumentLink).

Best Practices

  • ContentDocumentLink.LinkedEntityId is how Salesforce Files relate to any parent record — an aggregate COUNT(Id) ... GROUP BY LinkedEntityId query gets attachment counts for every closing Opportunity in one call.
  • A Set<String> of closing stage names avoids duplicating the "Closed Won or Closed Lost" check as two separate conditions everywhere it's needed.
  • Only Opportunities genuinely transitioning into a closed stage are checked — editing an already-closed Opportunity for an unrelated field should not re-trigger this validation.
Approach
  • 1CLOSING_STAGES.contains(newOpp.StageName) && !CLOSING_STAGES.contains(oldOpp.StageName) detects entering a closed stage from an open one.
  • 2ContentDocumentLink.LinkedEntityId holds the Id of whatever record a File is attached to — filter WHERE LinkedEntityId IN :opportunityIdsClosing.
  • 3Treat an Opportunity missing entirely from the aggregate result as having 0 documents.
Hard Triggers

79. Synchronize Custom Object Records Automatically

Problem #479 · Salesforce Apex Coding Challenge

Business Scenario

Every Product2 can have one or more related Warranty__c records (custom object: Product__c lookup to Product2, Status__c picklist Active/Discontinued). When a Product is deactivated or reactivated (IsActive changes), every one of its Warranty records must be synchronized to match — discontinued products should never keep an "Active" warranty.

Requirements

  • After update trigger on Product2.
  • Only act when IsActive actually changed on the Product.
  • Update every related Warranty__c's Status__c to match the Product's new active state, skipping Warranties that already match.

Best Practices

  • Detect the real field-level change (IsActive differs from Trigger.oldMap) before doing any query — Products saved with unrelated field edits should not trigger a Warranty sync at all.
  • One SOQL query pulls every affected Warranty for every changed Product, and one bulk DML call updates them — no per-Product query or update.
  • Skip Warranties whose Status__c already matches the expected value to avoid a no-op write.
Approach
  • 1Compare newProd.IsActive != oldProd.IsActive to detect a genuine change, not just any update.
  • 2Map productsById built from Trigger.new gives O(1) lookup of the parent Product2 for each Warranty.
  • 3parentProduct.IsActive ? 'Active' : 'Discontinued' computes the expected Warranty status directly from the Product's new state.

Practice All 79 Problems Free

Write and run real Apex code right in your browser — instant pass/fail feedback, best-practice linting, and governor limit monitoring. No Salesforce org needed.

Create Free Account → Explore All Problems
More Practice Topics
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