Batchable Apex

Batch Apex Practice Problems

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

This guide walks through 58 Database.Batchable implementations for processing large record volumes safely within governor limits. 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. Batch Apex Deduplication
  2. 2. Batch Apex Opportunity Archiver
  3. 3. Batch Apex — Track Failed Record Updates
  4. 4. Batch Apex: Categorise Accounts by Annual Revenue Tier
  5. 5. Batch Apex: Deactivate Stale Contacts with Partial DML
  6. 6. Batch Apex: Sync Contact Title from Account Industry
  7. 7. Batch Apex: Recalculate Account Engagement Score with Error Logging
  8. 8. Batch Apex (Easy): Mark Stale Opportunities as Closed Lost
  9. 9. Batch Apex (Easy-Medium): Stateful Batch — Count Processed Leads
  10. 10. Batch Apex (Medium): Contact Email Domain Enforcement with Partial DML
  11. 11. Batch Apex (Medium): Sync Account Billing Address to Child Contacts
  12. 12. Batch Apex (Hard): Chained Batch — Revenue Rollup then Account Tier
  13. 13. Schedulable (Easy): Weekly Batch Job Scheduler
  14. 14. Schedulable (Medium): Monthly Lead Summary Email
  15. 15. Schedulable (Hard): Self-Rescheduling Hourly Data Monitor
  16. 16. Batch Apex (Easy): Deactivate Users Inactive for 90+ Days
  17. 17. Batch Apex (Easy-Medium): Stateful Batch — Escalate Stale Cases
  18. 18. Batch Apex (Medium): Create Renewal Reminder Tasks for Expiring Contracts
  19. 19. Batch Apex (Medium-Hard): Reassign Opportunities from Inactive Users
  20. 20. Batch Apex (Hard): Calculate Account Health Score via Subquery Rollup
  21. 21. Batch Apex: Tag Accounts With No Contacts or Opportunities
  22. 22. Batch Apex: Create Follow-Up Tasks for Overdue Open Opportunities
  23. 23. Batch Apex: Stateful — Count How Many Contacts Were Updated
  24. 24. Batch Apex: Auto-Close Cases Waiting on Customer for 14+ Days
  25. 25. Batch Apex: Reassign Uncontacted Leads Older Than 30 Days to a Queue
  26. 26. Batch Apex: Update Inactive Contacts
  27. 27. Batch Apex: Delete Duplicate Leads by Email
  28. 28. Batch Apex: Archive Closed Opportunities Older Than 2 Years
  29. 29. Batch Apex: Update Account Ratings by Annual Revenue
  30. 30. Batch Apex: Yearly Sales Performance Aggregation (Stateful)
  31. 31. Batch Apex: Mark Inactive Accounts with No Open Opportunities
  32. 32. Batch Apex: Archive Closed Cases Older Than 2 Years
  33. 33. Batch Apex: Update Opportunity Probability by Stage Name
  34. 34. Batch Apex: Round-Robin Lead Assignment (Stateful)
  35. 35. Batch Apex: Clean Duplicate Contacts by Account and Email
  36. 36. Batch Apex: Chain a Follow-Up Batch From finish()
  37. 37. Batch Apex: Process a Fixed Account List Using a Custom Iterable
  38. 38. Batch Apex: Log Failed Records with Database.Stateful
  39. 39. Batch Apex: Sync Accounts to an External System via HTTP Callout
  40. 40. Batch Apex: Flag Leads With No Activity in 90 Days
  41. 41. Batch Apex: Update Parent and Child Records in One Pass
  42. 42. Scenario: Downgrade Inactive Premium Accounts to Standard Tier
  43. 43. Scenario: Reassign Accounts to a New Owner with an Audit Trail
  44. 44. Scenario: Governor-Safe Data Backfill With Resumable Checkpointing
  45. 45. Scenario: Nightly Account Tiering Pipeline — Batch Computes, Queueable Notifies
  46. 46. Scenario: Build a Configurable Data-Quality Rule Runner Using Dynamic Apex
  47. 47. Batch Apex: Monthly Customer Status Refresh
  48. 48. Batch Apex: Delete Expired Campaign Members
  49. 49. Batch Apex: Annual Product Price Adjustment
  50. 50. Batch Apex: Realign Opportunity Forecast Categories
  51. 51. Batch Apex: Reassign Open Cases to Active Agents by Capacity
  52. 52. Batch Apex: Generate Yearly Invoices for Active Contracts
  53. 53. Batch Apex: Internal ERP Data Reconciliation
  54. 54. Batch Apex: Nightly Product Catalog Import From Staging
  55. 55. Batch Apex: Bulk Recalculate Customer Loyalty Points
  56. 56. Batch Apex: Recalculate Fulfillment Status Across Millions of Orders
  57. 57. Batch Apex: Generate Enterprise-Wide Sales Summary Reports
  58. 58. Batch Apex: Renew or Expire Global Subscriptions Across Currencies
Medium BatchGovernor

1. Batch Apex Deduplication

Problem #4 · Salesforce Apex Coding Challenge

Problem Statement

Build a Database.Batchable class that finds and merges duplicate Lead records sharing the same email address.

Keep the most recently created record and merge others into it. Log results to a custom object Merge_Log__c.

Requirements

  • Batch size: 200 records
  • Use Database.merge() for the actual merging
  • The finish() method must send a summary email
Approach
  • 1The first item in each email group (sorted DESC by CreatedDate) is the master record to keep.
  • 2Call Database.merge(masterLead, duplicateIds) — it handles field merging automatically.
  • 3In finish(), use Messaging.sendEmail() with a SingleEmailMessage to notify an admin.
Hard Batch ApexApex Classes

2. Batch Apex Opportunity Archiver

Problem #75 · Salesforce Apex Coding Challenge

Problem Statement

Write a Batch Apex class that archives closed Opportunities older than a given number of days.

Requirements:

  • Implement Database.Batchable<SObject> interface
  • Constructor accepts Integer daysOld (archive opps closed more than this many days ago)
  • start(): return a Database.QueryLocator for Opportunities where StageName IN ('Closed Won','Closed Lost') AND CloseDate < :cutoffDate
  • execute(): set Archived__c = true on each record in the batch and update
  • finish(): send an email to the running user with the batch completion summary
  • Also implement Database.Stateful to track total records processed across batches

Constraints

  • Use Database.update(scope, false) for partial success
  • Track failed record count using a stateful instance variable
Approach
  • 1Use Date.today().addDays(-daysOld) to compute the cutoff date
  • 2Database.update(records, false) allows partial success — check results for failures
  • 3In finish(), use Messaging.SingleEmailMessage to send the summary email
  • 4UserInfo.getUserEmail() gets the running user's email address
Hard BatchGovernorError Handling

3. Batch Apex — Track Failed Record Updates

Problem #140 · Salesforce Apex Coding Challenge

Problem Statement

1,000 Account records are processed by a Batch Apex job that updates a custom field Last_Processed__c (DateTime). Some records may fail (e.g., validation rules, locked records).

Write the batch class so it:

  • Uses Database.update(records, false) (partial success) instead of the all-or-nothing DML.
  • Collects each failed record's Id and error message into a custom object Batch_Error_Log__c with fields Record_Id__c and Error_Message__c.
  • In finish(), sends an email summarising total records processed and failures.

Best Practices

  • Batch size: 200.
  • Do NOT re-throw exceptions — log them instead.
  • Test with Database.executeBatch() inside Test.startTest()/stopTest().
Approach
  • 1Database.update(scope, false) returns List.
  • 2Iterate results: if (!sr.isSuccess()) collect sr.getErrors()[0].getMessage().
  • 3Insert Batch_Error_Log__c records after collecting failures — one insert per execute() batch.
  • 4In finish(), query total Batch_Error_Log__c count and send via Messaging.SingleEmailMessage.
Medium Batch ApexAsync Apex

4. Batch Apex: Categorise Accounts by Annual Revenue Tier

Problem #204 · Salesforce Apex Coding Challenge

Problem Statement

Write a Batch Apex class AccountRevenueTierBatch that reads every Account and sets a custom field Revenue_Tier__c based on AnnualRevenue:

  • Enterprise — AnnualRevenue ≥ 1,000,000
  • Medium — AnnualRevenue 100,000 – 999,999
  • Small — AnnualRevenue < 100,000 or null

Requirements

  • Implement Database.Batchable<SObject> with all three methods.
  • start(): query Id, AnnualRevenue, Revenue_Tier__c FROM Account.
  • execute(): assign tier, skip records where value did not change, call Database.update(list, false) outside the loop.
  • finish(): log completion with System.debug.

Best Practices

  • Treat null AnnualRevenue as 0 — never call arithmetic on null.
  • Guard DML: if (!toUpdate.isEmpty()) before updating.
  • Database.update(list, false) allows partial success — safe for bulk runs.
  • Never place SOQL or DML inside a loop.
Approach
  • 1Null-safe: Decimal rev = acc.AnnualRevenue != null ? acc.AnnualRevenue : 0;
  • 2Ternary chain: rev >= 1000000 ? 'Enterprise' : rev >= 100000 ? 'Medium' : 'Small'
  • 3Skip unchanged records: if (acc.Revenue_Tier__c != tier) { acc.Revenue_Tier__c = tier; toUpdate.add(acc); }
  • 4Partial-success DML: Database.update(toUpdate, false) — the false flag prevents all-or-nothing rollback.
Hard Batch ApexAsync Apex

5. Batch Apex: Deactivate Stale Contacts with Partial DML

Problem #205 · Salesforce Apex Coding Challenge

Problem Statement

Write StaleContactDeactivationBatch that marks Contacts as stale when they have had no activity for 24 months.

Requirements

  • Implement Database.Batchable<SObject> and Database.Stateful.
  • Declare instance variables processedCount and failedCount (both Integer, both global).
  • start(): filter Contacts where Is_Stale__c = false AND (LastActivityDate < 24 months ago OR LastActivityDate = null).
  • execute(): set Is_Stale__c = true, call Database.update(list, false), iterate SaveResult to count successes and failures, log each error with System.debug.
  • finish(): log both counts.

Best Practices

  • Use Database.Stateful to accumulate counts across batches.
  • Inspect every Database.SaveResult — never silently swallow errors.
  • Cutoff date: Date.today().addMonths(-24).
Approach
  • 1Implement both Database.Batchable AND Database.Stateful to persist counts across batches.
  • 2Cutoff: Date cutoff = Date.today().addMonths(-24); — use a bind variable in the SOQL string.
  • 3Capture results: List results = Database.update(toUpdate, false);
  • 4Check each result: if (sr.isSuccess()) processedCount++; else { failedCount++; for (Database.Error err : sr.getErrors()) System.debug(err.getMessage()); }
Medium Batch ApexAsync Apex

6. Batch Apex: Sync Contact Title from Account Industry

Problem #219 · Salesforce Apex Coding Challenge

Problem Statement

Write ContactTitleSyncBatch that reads all Contacts with an Account and sets the Contact's Title field based on the parent Account's Industry.

Industry → Title Mapping

  • Technology'Technology Professional'
  • Finance'Finance Professional'
  • Healthcare'Healthcare Professional'
  • Any other'Industry Professional'

Requirements

  • Implement Database.Batchable<SObject>.
  • start(): query Contacts (with their Title and the parent Account's Industry via a child-to-parent relationship), restricted to Contacts that actually have a parent Account.
  • execute(): skip Contacts with null Industry; only update when title changes; wrap DML in try-catch(DmlException e).
  • finish(): log with System.debug.

Best Practices

  • Use child-to-parent SOQL (Account.Industry) — avoids a second query.
  • Skip null industry with continue — defensive coding.
  • Wrap DML in try-catch to log errors without aborting the batch.
  • Only add records to the update list when the value actually changes.
Approach
  • 1Reference the parent field directly in the query with dot notation to avoid a second query for industry data.
  • 2Read parent field: String industry = c.Account != null ? c.Account.Industry : null;
  • 3Skip null: if (industry == null) continue;
  • 4Wrap DML: try { update toUpdate; } catch (DmlException e) { System.debug(e.getMessage()); }
Hard Batch ApexAsync Apex

7. Batch Apex: Recalculate Account Engagement Score with Error Logging

Problem #220 · Salesforce Apex Coding Challenge

Problem Statement

Write AccountEngagementScoreBatch that calculates a custom Engagement_Score__c for every Account using three signals, and logs all errors via Database.Stateful.

Scoring Rules

  • +40 if AnnualRevenue ≥ 1,000,000
  • +30 if NumberOfEmployees ≥ 100
  • +30 if Account has at least one Contact (use a subquery)

Requirements

  • Implement Database.Batchable<SObject> and Database.Stateful.
  • Declare: global Integer successCount, global Integer errorCount, global List<String> errorLog.
  • start(): include a parent-to-child subquery that checks for the mere existence of at least one related Contact, without pulling back more than needed.
  • execute(): wrap per-record scoring in try-catch(Exception e); call Database.update(list, false); inspect every Database.SaveResult.
  • finish(): log counts and every entry in errorLog.
Approach
  • 1A parent-to-child subquery capped at a single row is an efficient way to check for the mere existence of at least one related Contact.
  • 2Check subquery result: if (!acc.Contacts.isEmpty()) score += 30;
  • 3Per-record try-catch catches unexpected NullPointerExceptions during scoring.
  • 4SaveResult inspection: for (Database.SaveResult sr : results) { if (sr.isSuccess()) successCount++; else { errorCount++; errorLog.add(...); } }
Easy BatchAsync Apex

8. Batch Apex (Easy): Mark Stale Opportunities as Closed Lost

Problem #232 · Salesforce Apex Coding Challenge

Problem Statement

Write a batch class StaleOpportunityBatch that marks all Prospecting Opportunities that were created more than 365 days ago as Closed Lost.

Requirements

  1. Implement Database.Batchable<SObject>.
  2. start() — return a Database.QueryLocator for Opportunities that are still in the Prospecting stage, not yet closed, and were created on or before a cutoff date (cutoff = today minus 365 days).
  3. execute() — set StageName = 'Closed Lost' and CloseDate = Date.today(); save with Database.update(scope, false).
  4. finish() — log the job Id with System.debug.

Best Practices

  • Use Database.getQueryLocator() — not a List — so Salesforce paginates up to 50M rows.
  • Guard execute() with scope.isEmpty().
  • Database.update(scope, false) — partial saves: one bad record does not roll back the chunk.
Approach
  • 1start(): Date cutoff = Date.today().addDays(-365);
  • 2Locator: return Database.getQueryLocator() scoped to Opportunity records in the Prospecting stage that are not closed and were created on or before the cutoff date.
  • 3execute(): for (Opportunity opp : scope) { opp.StageName = 'Closed Lost'; opp.CloseDate = Date.today(); }
  • 4Partial save: Database.update(scope, false) — allOrNone=false prevents one failure from rolling back the chunk.
  • 5finish(): System.debug('Complete. Job: ' + bc.getJobId());
Easy BatchAsync Apex

9. Batch Apex (Easy-Medium): Stateful Batch — Count Processed Leads

Problem #233 · Salesforce Apex Coding Challenge

Problem Statement

Write LeadScoreResetBatch that implements both Database.Batchable<SObject> and Database.Stateful. It resets Lead_Score__c to 0 on all unconverted Leads and tracks how many records succeeded and failed across all chunks.

Requirements

  1. Implement both Database.Batchable<SObject> and Database.Stateful.
  2. Declare private Integer processedCount = 0 and private Integer failedCount = 0 as instance variables.
  3. start() — query unconverted Leads with Lead_Score__c > 0.
  4. execute() — reset Lead_Score__c = 0, save with Database.update(scope, false), inspect Database.SaveResult[] to increment counters.
  5. finish() — log the totals.

Key Concept: Database.Stateful

Without Database.Stateful, instance variables are reset to their defaults between each execute() call. With it, they persist across all chunks — essential for aggregating results.

Approach
  • 1Database.Stateful interface keeps instance variables alive between execute() chunks.
  • 2Database.SaveResult[] results = Database.update(scope, false); — inspect each result.
  • 3SaveResult check: if (sr.isSuccess()) { processedCount++; } else { failedCount++; }
  • 4finish() can read processedCount directly because Stateful preserves it.
  • 5Run: Database.executeBatch(new LeadScoreResetBatch(), 200);
Medium BatchAsync Apex

10. Batch Apex (Medium): Contact Email Domain Enforcement with Partial DML

Problem #234 · Salesforce Apex Coding Challenge

Problem Statement

Write ContactEmailDomainBatch that enforces an allowed email domain (salesforce.com) across all Contacts. Contacts with non-compliant emails should have their Email field cleared. Use partial DML and capture errors.

Requirements

  1. Implement Database.Batchable<SObject> and Database.Stateful.
  2. Declare constants/fields: ALLOWED_DOMAIN, errorLog, successCount.
  3. start() — query Contacts WHERE Email != null.
  4. execute() — for each Contact, if email doesn't end with @salesforce.com, set Email = null and add to update list. Use Database.update(toUpdate, false) and inspect SaveResult[].
  5. finish() — log success count and all error messages.

Best Practices

  • Use a local toUpdate list — don't update all scope records, only those that changed.
  • Guard with toUpdate.isEmpty() before DML — avoids unnecessary governor usage.
  • Use indexed access results[i] alongside toUpdate[i] to correlate errors back to the correct record Id.
Approach
  • 1Domain check: !c.Email.toLowerCase().endsWith('@' + ALLOWED_DOMAIN)
  • 2Only update records that changed — collect into toUpdate list before DML.
  • 3Correlate errors: for (Integer i = 0; i < results.size(); i++) { if (!results[i].isSuccess()) ... toUpdate[i].Id ... }
  • 4Database.Error: results[i].getErrors() returns List; each has getMessage().
  • 5String.isBlank() handles both null and empty string email values.
Medium BatchAsync Apex

11. Batch Apex (Medium): Sync Account Billing Address to Child Contacts

Problem #235 · Salesforce Apex Coding Challenge

Problem Statement

Write AccountAddressSyncBatch that copies each Account's billing address to all of its child Contacts' mailing address fields in bulk.

Requirements

  1. Implement Database.Batchable<SObject>.
  2. start() — query Accounts that actually have a billing city populated.
  3. execute():
    • Collect Account Ids from the scope batch.
    • Build a Map<Id, Account> from scope for O(1) lookup.
    • Query all child Contacts belonging to those accounts in a single SOQL call.
    • Copy BillingCity → MailingCity, BillingState → MailingState, BillingPostalCode → MailingPostalCode, BillingCountry → MailingCountry.
    • Bulk update with Database.update(toUpdate, false).

Key Challenge

A SOQL inside execute() is allowed (one per chunk) — but it must be outside any loop. Build the Id set first, then query once.

Approach
  • 1Map from scope: Map accountMap = new Map(scope); — Map constructor accepts a List.
  • 2Query the Contact object filtered to the accountIds set built earlier, bringing back the mailing fields you plan to overwrite.
  • 3Copy: c.MailingCity = acc.BillingCity; c.MailingState = acc.BillingState; etc.
  • 4One SOQL per execute() chunk is allowed — keep it outside the for loop.
  • 5Guard: if (!toUpdate.isEmpty()) Database.update(toUpdate, false);
Hard BatchAsync Apex

12. Batch Apex (Hard): Chained Batch — Revenue Rollup then Account Tier

Problem #236 · Salesforce Apex Coding Challenge

Problem Statement

Implement two chained batch classes:

  1. RevenueRollupBatch — calculates total closed-won revenue per Account (using a parent-child subquery), writes it to Total_Closed_Won_Revenue__c, then chains AccountTierAssignmentBatch in finish().
  2. AccountTierAssignmentBatch — assigns Account_Tier__c (Platinum/Gold/Silver/Bronze) based on the revenue field.

Chaining Rules

  • Guard chain in finish() with if (!Test.isRunningTest()) — you cannot enqueue from inside a batch during test execution.
  • RevenueRollupBatch must be Database.Stateful to count processed Accounts across chunks.
  • Skip records where the tier has not changed — only DML when needed.
Approach
  • 1Parent-child subquery: query Account with a nested subquery on its related Opportunities, filtered to IsClosed = true and IsWon = true, so each Account carries its own closed-won Opportunities.
  • 2Sum child records: for (Opportunity opp : acc.Opportunities) { if (opp.Amount != null) total += opp.Amount; }
  • 3Chain in finish(): if (!Test.isRunningTest()) { Database.executeBatch(new AccountTierAssignmentBatch(), 200); }
  • 4Tier logic: rev >= 1000000 ? 'Platinum' : rev >= 500000 ? 'Gold' : rev >= 100000 ? 'Silver' : 'Bronze'
  • 5Skip unchanged: if (acc.Account_Tier__c != tier) { acc.Account_Tier__c = tier; toUpdate.add(acc); }
Easy BatchAsync Apex

13. Schedulable (Easy): Weekly Batch Job Scheduler

Problem #237 · Salesforce Apex Coding Challenge

Problem Statement

Write WeeklyOpportunityCleanupScheduler that implements Schedulable and kicks off StaleOpportunityBatch on a scheduled basis.

Requirements

  • Implements Schedulable.
  • Declare a @TestVisible private Integer batchSize = 200 field.
  • execute(SchedulableContext sc) — creates a new StaleOpportunityBatch() and calls Database.executeBatch(batch, batchSize).

How to Schedule

String cron = '0 0 2 ? * MON *'; // Every Monday at 2 AM
String jobName = 'Weekly Opp Cleanup';
System.schedule(jobName, cron, new WeeklyOpportunityCleanupScheduler());

Best Practices

  • Store batch size in an @TestVisible field so tests can override it.
  • Keep execute() minimal — one line to create and enqueue the batch.
  • Salesforce CRON: Seconds Minutes Hours Day Month DayOfWeek Year.
Approach
  • 1Class signature: public class WeeklyOpportunityCleanupScheduler implements Schedulable
  • 2execute() signature: public void execute(SchedulableContext sc)
  • 3@TestVisible lets test classes set batchSize without exposing it publicly.
  • 4Batch kick-off: Database.executeBatch(new StaleOpportunityBatch(), batchSize);
  • 5Schedule via Apex: System.schedule('Weekly Opp Cleanup', '0 0 2 ? * MON *', new WeeklyOpportunityCleanupScheduler());
Medium BatchAsync Apex

14. Schedulable (Medium): Monthly Lead Summary Email

Problem #238 · Salesforce Apex Coding Challenge

Problem Statement

Write MonthlyLeadSummaryScheduler that implements Schedulable and emails a monthly report of new unconverted Leads to an admin.

Requirements

  1. Implements Schedulable.
  2. @TestVisible private String recipientEmail = 'admin@salesforce.com'.
  3. execute(SchedulableContext sc):
    • Query a COUNT() of Leads that are unconverted (IsConverted = false) and were created during the current month, using the THIS_MONTH date literal.
    • Build a Messaging.SingleEmailMessage with subject and body.
    • Send via Messaging.sendEmail() guarded by !Test.isRunningTest().

Best Practices

  • Guard Messaging.sendEmail() with !Test.isRunningTest() — sending email in tests uses the daily email allowance.
  • Use the THIS_MONTH date literal — cleaner than calculating start/end dates.
  • Store the recipient in a @TestVisible field for testability.
Approach
  • 1SOQL COUNT: run a COUNT() query on Lead, filtered to unconverted records (IsConverted = false) created during the current month (THIS_MONTH), and store it in Integer leadCount.
  • 2Email: Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
  • 3setToAddresses() takes List: email.setToAddresses(new List{ recipientEmail });
  • 4Guard sending: if (!Test.isRunningTest()) { Messaging.sendEmail(new List{ email }); }
  • 5Date format: Date.today().month() + '/' + Date.today().year()
Hard BatchAsync Apex

15. Schedulable (Hard): Self-Rescheduling Hourly Data Monitor

Problem #239 · Salesforce Apex Coding Challenge

Problem Statement

Write HourlyDataMonitorScheduler that runs every hour, counts new open Cases, alerts on spikes, and re-schedules itself for the next hour automatically.

Requirements

  1. Implements Schedulable.
  2. private static final String JOB_NAME = 'HourlyDataMonitor'.
  3. @TestVisible private static String buildNextHourCron() — builds a CRON expression for exactly one hour from now using DateTime.now().addHours(1) and String.format().
  4. execute():
    • Count Cases created in the last hour: CreatedDate >= :oneHourAgo.
    • Log count. If count > 50, log an alert.
    • Abort current job with System.abortJob(sc.getTriggerId()).
    • Re-schedule with System.schedule(JOB_NAME, buildNextHourCron(), new HourlyDataMonitorScheduler()).
    • Guard all scheduling code with !Test.isRunningTest().

Self-Rescheduling Pattern

Salesforce Scheduled Jobs run once. To simulate a recurring job at intervals shorter than 1 day (which standard Apex Scheduling via UI cannot express), the job re-schedules itself in execute(). Always abort the current job first to avoid duplicate schedules building up.

Approach
  • 1CRON format: '0 0 {hour} {day} {month} ? {year}' — second, minute, hour, day, month, day-of-week(?), year.
  • 2String.format: String.format('0 0 {0} {1} {2} ? {3}', new List{ nextHour.hour(), nextHour.day(), nextHour.month(), nextHour.year() })
  • 3Abort current: System.abortJob(sc.getTriggerId()); — prevents the scheduler from firing the old CRON again.
  • 4Re-schedule: System.schedule(JOB_NAME, buildNextHourCron(), new HourlyDataMonitorScheduler());
  • 5Always guard scheduling code: if (!Test.isRunningTest()) { ... } — prevents test failures from scheduling conflicts.
  • Easy BatchAsync Apex

    16. Batch Apex (Easy): Deactivate Users Inactive for 90+ Days

    Problem #240 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class InactiveUserDeactivateBatch that deactivates all active Salesforce Users who have not logged in for 90 or more days. Also write a complete @isTest class that covers all three batch methods.

    Requirements — Batch Class

    1. Implement Database.Batchable<SObject>.
    2. start() — return a Database.QueryLocator for Users that are still active and have not logged in since a cutoff date (LastLoginDate <= :cutoff, cutoff = today minus 90 days).
    3. execute() — set IsActive = false on each User; save with Database.update(scope, false).
    4. finish() — log the Job Id with System.debug.

    Requirements — Test Class

    • Use @isTest annotation and Test.startTest() / Test.stopTest().
    • Test that Database.executeBatch() runs without error.
    • Test that start() returns a non-null QueryLocator.
    • Test that execute() handles an empty scope gracefully.
    Approach
    • 1Cutoff: Date cutoff = Date.today().addDays(-90);
    • 2Query: fetch active Users (IsActive = true) whose LastLoginDate <= :cutoff.
    • 3execute(): for (User u : scope) { u.IsActive = false; } then Database.update(scope, false)
    • 4Test batch: Test.startTest(); Database.executeBatch(new InactiveUserDeactivateBatch(), 200); Test.stopTest();
    • 5Test start(): Database.QueryLocator ql = batch.start(null); System.assertNotEquals(null, ql, ...);
    Easy BatchAsync Apex

    17. Batch Apex (Easy-Medium): Stateful Batch — Escalate Stale Cases

    Problem #241 · Salesforce Apex Coding Challenge

    Problem Statement

    Write CaseEscalationBatch that escalates open Cases older than 7 days by setting their Priority to 'High'. Cases already at 'High' priority must be skipped. Use Database.Stateful to track escalated and skipped counts. Also write a complete @isTest class with data setup and assertions.

    Requirements — Batch Class

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Integer escalatedCount = 0 and private Integer skippedCount = 0.
    3. start() — query Cases WHERE Status != 'Closed' AND CreatedDate <= :cutoff (7 days ago).
    4. execute() — skip already-High cases (increment skippedCount); set Priority = 'High' on others and increment escalatedCount on success.
    5. finish() — log both counters.

    Requirements — Test Class

    • Use @TestSetup to insert test Cases (some Low priority, one already High).
    • Test that non-High cases are escalated to High after the batch runs.
    • Test that already-High cases are unchanged.
    • Use Test.startTest() / Test.stopTest() around Database.executeBatch().
    Approach
    • 1Stateful: implements Database.Batchable, Database.Stateful
    • 2Skip check: if (c.Priority == 'High') { skippedCount++; continue; }
    • 3Escalate: c.Priority = 'High'; toUpdate.add(c);
    • 4Test setup: @TestSetup static void makeData() — insert Cases before each test method.
    • 5Test run: Test.startTest(); Database.executeBatch(new CaseEscalationBatch(), 200); Test.stopTest();
    Medium BatchAsync Apex

    18. Batch Apex (Medium): Create Renewal Reminder Tasks for Expiring Contracts

    Problem #242 · Salesforce Apex Coding Challenge

    Problem Statement

    Write ContractRenewalReminderBatch that creates a Task reminder for every Activated Contract expiring in the next 30 days. Track created and error counts across all chunks.

    Requirements

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Integer createdCount = 0 and private Integer errorCount = 0.
    3. start() — query Contracts that are Activated and whose EndDate falls within the next 30 days, using a relative date literal rather than computed dates.
    4. execute() — for each Contract, create a Task with:
      • Subject = 'Contract Renewal: ' + con.ContractNumber
      • WhatId = con.Id
      • ActivityDate = Date.today().addDays(1)
      • Status = 'Not Started', Priority = 'High'
      Use Database.insert(toInsert, false) and inspect SaveResult[].
    5. finish() — log both counters.

    Key Concept: Relative Date Literals

    SOQL provides relative date literals meaning "from today to N days from now" — no Apex date math needed in the query.

    Approach
    • 1SOQL has a relative date literal for "the next N days" that you can compare a date field against directly — no Apex date variable needed.
    • 2Task fields: t.Subject = 'Contract Renewal: ' + con.ContractNumber; t.WhatId = con.Id;
    • 3Activity date: t.ActivityDate = Date.today().addDays(1);
    • 4DML: Database.SaveResult[] results = Database.insert(toInsert, false);
    • 5Error detail: for (Database.Error err : sr.getErrors()) System.debug(err.getMessage());
    Hard BatchAsync Apex

    19. Batch Apex (Medium-Hard): Reassign Opportunities from Inactive Users

    Problem #243 · Salesforce Apex Coding Challenge

    Problem Statement

    Write OpportunityOwnerReassignBatch that reassigns all open Opportunities owned by inactive Users to a configurable default owner. Track the total number of reassignments across all chunks.

    Requirements

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Id defaultOwnerId and private Integer reassignedCount = 0.
    3. Constructor accepts Id defaultOwnerId and stores it via this.defaultOwnerId.
    4. start() — query open Opportunities (IsClosed = false) whose owner is inactive, using the cross-object filter Owner.IsActive = false.
    5. execute() — set OwnerId = defaultOwnerId on each Opportunity. Use Database.update(toUpdate, false) and inspect SaveResult[].
    6. finish() — log reassignedCount.

    Key Concept: Cross-Object SOQL Filter

    Owner.IsActive = false in the WHERE clause traverses the User relationship directly in SOQL — no Apex-side filtering needed.

    Approach
    • 1Cross-object filter: filter to open Opportunities (IsClosed = false) whose Owner.IsActive = false — Salesforce resolves the User relationship directly in SOQL.
    • 2Constructor: public OpportunityOwnerReassignBatch(Id defaultOwnerId) { this.defaultOwnerId = defaultOwnerId; }
    • 3Reassign: opp.OwnerId = defaultOwnerId; toUpdate.add(opp);
    • 4SaveResult: Database.SaveResult[] results = Database.update(toUpdate, false);
    • 5Run: Database.executeBatch(new OpportunityOwnerReassignBatch(someUserId), 200);
    Hard BatchAsync Apex

    20. Batch Apex (Hard): Calculate Account Health Score via Subquery Rollup

    Problem #244 · Salesforce Apex Coding Challenge

    Problem Statement

    Write AccountHealthScoreBatch that calculates and saves an Account_Health_Score__c (0–100) for every Account. The score is derived from open Opportunity count and open Case count using parent-child subqueries in start().

    Requirements

    1. Implement Database.Batchable<SObject> and Database.Stateful.
    2. Declare private Integer processedCount = 0 and private Integer errorCount = 0.
    3. start() — use a parent-with-child subquery to retrieve each Account's open Opportunities AND open Cases in one query.
    4. execute():
      • Read: acc.Cases.size() and acc.Opportunities.size().
      • Formula: healthScore = 100 - (openCaseCount * 10) + (openOppCount * 5)
      • Clamp: Math.max(0, Math.min(100, healthScore)).
      • Save to Account_Health_Score__c; increment processedCount.
    5. finish() — log both counters.

    Key Concept: Parent-Child Subquery in Batch start()

    Placing a subquery inside Database.getQueryLocator fetches related child records for each parent in the same SOQL, completely avoiding SOQL-in-loop violations.

    Approach
    • 1Subquery: pull each Account's open Opportunities (with a non-null Amount) as a child subquery nested inside the Account query.
    • 2Read child list: Integer openCaseCount = acc.Cases.size(); — subquery results are Lists on the parent.
    • 3Formula: Integer healthScore = 100 - (openCaseCount * 10) + (openOppCount * 5);
    • 4Clamp: healthScore = Math.max(0, Math.min(100, healthScore));
    • 5Custom field: acc.Account_Health_Score__c = healthScore; — assumes field exists on Account.
    Medium Batch Apex

    21. Batch Apex: Tag Accounts With No Contacts or Opportunities

    Problem #290 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class NoActivityAccountBatch that sets Description to 'Needs Review' on every Account that has no Contacts and no Opportunities.

    Requirements

    • Implement Database.Batchable<SObject>
    • start() — query ALL accounts with subqueries for Contacts and Opportunities (LIMIT 1 each)
    • execute() — check both subquery lists, update only qualifying accounts
    • Guard DML with an isEmpty() check
    • Use Database.update(list, false) for partial-DML safety
    • finish() — log completion with System.debug
    • Store the flag value 'Needs Review' in a named constant
    Approach
    • 1Use subqueries inside the SELECT: (SELECT Id FROM Contacts LIMIT 1) and (SELECT Id FROM Opportunities LIMIT 1).
    • 2In execute(), access them as acc.Contacts and acc.Opportunities — check isEmpty().
    • 3Database.update(list, false) allows partial success; failed records are skipped.
    • 4Always guard the DML call with if (!toUpdate.isEmpty()).
    Medium Batch Apex

    22. Batch Apex: Create Follow-Up Tasks for Overdue Open Opportunities

    Problem #291 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class OverdueOppFollowUpBatch that creates a follow-up Task for every open Opportunity whose CloseDate is in the past.

    Requirements

    • Implement Database.Batchable<SObject>
    • start() — query open opportunities with IsClosed = false AND CloseDate < TODAY
    • execute() — create one Task per opportunity:
      • Subject: 'Follow Up: ' + opp.Name
      • WhatId: opportunity Id
      • OwnerId: opportunity owner
      • ActivityDate: today + 3 days (use a constant)
      • Status = 'Not Started', Priority = 'High'
    • Use Database.insert(tasks, false) with isEmpty() guard
    • Store the due-day offset in a named constant
    Approach
    • 1Filter with IsClosed = false AND CloseDate < TODAY to find overdue open opportunities.
    • 2WhatId links the Task to the Opportunity record (activity relation).
    • 3Date.today().addDays(TASK_DUE_DAYS) computes the due date at runtime.
    • 4Database.insert(tasks, false) allows partial success if individual task inserts fail.
    Medium Batch Apex

    23. Batch Apex: Stateful — Count How Many Contacts Were Updated

    Problem #292 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a stateful batch class ContactTitleNormalizeBatch that normalises the Title field of every Contact to sentence-case (first letter upper, rest lower) and counts the total number of records updated across all batches.

    Requirements

    • Implement both Database.Batchable<SObject> and Database.Stateful
    • Declare a global Integer totalUpdated = 0 instance variable
    • start() — query Contacts where Title != null
    • execute() — trim, lower-case, then capitalise the first character; skip records already correct
    • Accumulate totalUpdated only when records are actually changed
    • finish() — log totalUpdated with System.debug
    Approach
    • 1Implement Database.Stateful to preserve instance variable values across execute() calls.
    • 2Sentence-case: str.trim().toLowerCase() then str.substring(0,1).toUpperCase() + str.substring(1).
    • 3Only increment totalUpdated by toUpdate.size() — not scope.size().
    • 4Guard the update with isEmpty() before calling Database.update(toUpdate, false).
    Medium Batch Apex

    24. Batch Apex: Auto-Close Cases Waiting on Customer for 14+ Days

    Problem #293 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class StaleWaitingCaseBatch that automatically closes Case records whose Status is 'Waiting on Customer' and have not been modified in 14 or more days.

    Requirements

    • Implement Database.Batchable<SObject>
    • Store thresholds in named constants: STALE_DAYS = 14, WAITING = 'Waiting on Customer', CLOSED_STATUS = 'Closed'
    • start() — compute cutoff as Date.today().addDays(-STALE_DAYS); filter Status = :WAITING AND LastModifiedDate <= :cutoff
    • execute() — set Status = 'Closed' and Description to a closure reason; use Database.update(list, false)
    • finish() — log completion
    Approach
    • 1Compute the cutoff date before the query string: Date cutoff = Date.today().addDays(-STALE_DAYS).
    • 2Use :WAITING and :cutoff as bind variables inside the SOQL string — they resolve from class scope.
    • 3LastModifiedDate <= :cutoff catches records not modified for 14 or more days.
    • 4Always use Database.update(list, false) in batches to avoid one bad record failing the entire chunk.
    Hard Batch Apex

    25. Batch Apex: Reassign Uncontacted Leads Older Than 30 Days to a Queue

    Problem #294 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a batch class UncontactedLeadReassignBatch that reassigns stale, uncontacted Lead records to a Salesforce Queue named 'Uncontacted_Leads'.

    Requirements

    • Implement Database.Batchable<SObject>
    • In the constructor, query the Group object (type = 'Queue') for the queue Id — store as an instance variable
    • Store constants: STALE_DAYS = 30, QUEUE_NAME = 'Uncontacted_Leads', STATUS_OPEN = 'Open - Not Contacted'
    • start() — query unconverted leads with the open status created more than 30 days ago
    • execute() — if queue found, set OwnerId = queueId; use Database.update(list, false)
    • finish() — log completion
    Approach
    • 1Query Group WHERE Type = 'Queue' AND DeveloperName = :QUEUE_NAME in the constructor to resolve the queue Id once.
    • 2Guard execute() with if (queueId == null) return; so the batch is a no-op when the queue does not exist.
    • 3Use IsConverted = false to exclude already-converted leads.
    • 4CreatedDate <= :cutoff (not LastModifiedDate) targets leads that are old, not just unmodified.
    Easy Batch Apex

    26. Batch Apex: Update Inactive Contacts

    Problem #352 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a Batch Apex class UpdateInactiveContactsBatch that marks all inactive Contacts by updating their Description field.

    A Contact is inactive when its LastActivityDate is older than 365 days, or when LastActivityDate is null and CreatedDate is older than 365 days.

    Requirements

    • Implement Database.Batchable<SObject> with all three methods.
    • Provide a no-arg constructor (defaults to Date.today().addDays(-365)) and an overloaded constructor accepting a custom Date cutoffDate for testability.
    • start(): return a Database.QueryLocator filtering by the inactivity criteria.
    • execute(): set Description = '[INACTIVE] Reviewed: ' + String.valueOf(Date.today()); do bulk DML outside the loop.
    • finish(): use System.debug to print the job ID — result visible in the Output tab.

    Best Practices

    • Mutate all records in the for loop, then call update scope once outside it.
    • Wrap DML in try/catch(DmlException).
    • Never place SOQL or DML inside a loop.
    Approach
    • 1The :cutoffDate bind variable in your SOQL string refers to the instance field declared at the class level — Apex resolves it automatically.
    • 2Mutate each Contact inside the loop; place update scope outside the loop after iteration ends.
    • 3Wrap update in try { ... } catch (DmlException e) { System.debug(LoggingLevel.ERROR, e.getMessage()); } to handle partial failures.
    • 4In finish(), System.debug('Job finished. Id: ' + bc.getJobId()) prints the result to the Output (Console) tab.
    Easy Batch Apex

    27. Batch Apex: Delete Duplicate Leads by Email

    Problem #353 · Salesforce Apex Coding Challenge

    Problem Statement

    Write DeleteDuplicateLeadsBatch that removes duplicate unconverted Leads, keeping the most recently created Lead per email address.

    Requirements

    • Implement Database.Batchable<SObject> and Database.Stateful.
    • Declare private Integer totalDeleted = 0 as a stateful counter.
    • start(): query non-converted Leads with a non-null Email, sorted so that within each email group the most recently created Lead comes first.
    • execute(): group Leads by email (case-insensitive), keep index 0 (newest), delete the rest using Database.delete(list, false).
    • Inspect every Database.DeleteResult; increment totalDeleted on success.
    • finish(): System.debug the total deleted count — visible in the Output tab.

    Best Practices

    • Use allOrNone = false so one bad record does not abort the entire delete.
    • Always inspect DeleteResult errors rather than swallowing them silently.
    • Guard DML with if (!toDelete.isEmpty()).
    Approach
    • 1Both interfaces on one line: implements Database.Batchable, Database.Stateful
    • 2Grouping: Map> emailToLeads — key is l.Email.toLowerCase() for case-insensitive match.
    • 3Sort your query so the newest lead in each group lands at index 0; delete indices 1, 2, ... into toDelete.
    • 4Database.delete(toDelete, false): false = allOrNone = false. Iterate results: if (!dr.isSuccess()) log dr.getErrors().
    Easy Batch Apex

    28. Batch Apex: Archive Closed Opportunities Older Than 2 Years

    Problem #354 · Salesforce Apex Coding Challenge

    Problem Statement

    Write ArchiveClosedOpportunitiesBatch that stamps the Description field of all Closed Won or Closed Lost Opportunities whose CloseDate is older than 2 years.

    Requirements

    • Implement Database.Batchable<SObject>.
    • Overloaded constructors: no-arg (cutoff = Date.today().addYears(-2)) and one accepting a custom Date cutoffDate.
    • start(): query Opportunities restricted to the Closed Won / Closed Lost stages whose CloseDate is older than the cutoff.
    • execute(): set Description = '[ARCHIVED] ' + today; bulk update outside the loop.
    • finish(): System.debug the job ID — visible in the Output tab.
    Approach
    • 1Build a Set of the closed stage names inside start(), then filter the query using an IN clause against that set (bind variable, not a hardcoded literal list).
    • 2Use Date.today().addYears(-2) for the default 2-year cutoff date.
    • 3Stamp Description for every opp inside the loop; call update scope once after the loop ends.
    • 4Wrap update in try { ... } catch (DmlException e) { System.debug(LoggingLevel.ERROR, e.getMessage()); }
    Medium Batch Apex

    29. Batch Apex: Update Account Ratings by Annual Revenue

    Problem #355 · Salesforce Apex Coding Challenge

    Problem Statement

    Write UpdateAccountRatingsBatch that reads every Account and sets the Rating picklist field based on AnnualRevenue:

    • Hot — AnnualRevenue ≥ 1,000,000,000
    • Warm — AnnualRevenue ≥ 100,000,000 and < 1,000,000,000
    • Cold — AnnualRevenue < 100,000,000 or null

    Requirements

    • Implement Database.Batchable<SObject>.
    • Declare both thresholds as private static final Decimal constants.
    • execute(): null-safe revenue check; assign Rating; bulk update outside loop.
    • finish(): System.debug the job ID — visible in the Output tab.
    Approach
    • 1Null-safe revenue: Decimal revenue = acct.AnnualRevenue != null ? acct.AnnualRevenue : 0;
    • 2Threshold chain: if (revenue >= HOT_THRESHOLD) acct.Rating = 'Hot'; else if (revenue >= WARM_THRESHOLD) acct.Rating = 'Warm'; else acct.Rating = 'Cold';
    • 3Mutate Rating inside the loop, call update scope once after.
    • 4private static final Decimal constants avoid magic numbers.

    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 →
    Medium Batch Apex

    30. Batch Apex: Yearly Sales Performance Aggregation (Stateful)

    Problem #356 · Salesforce Apex Coding Challenge

    Problem Statement

    Write YearlySalesPerformanceBatch that aggregates total Closed Won Opportunity revenue for the current calendar year per Account and writes the result to each Account's Description field.

    Requirements

    • Implement Database.Batchable<SObject> and Database.Stateful.
    • Declare private Map<Id, Decimal> accountAmountMap to accumulate totals.
    • execute(): accumulate Amountno DML here.
    • finish(): bulk update Accounts; System.debug the count — visible in the Output tab.
    Approach
    • 1Implement both: implements Database.Batchable, Database.Stateful
    • 2Accumulation: Decimal current = accountAmountMap.containsKey(id) ? accountAmountMap.get(id) : 0; accountAmountMap.put(id, current + amount);
    • 3Year bounds: Integer yr = Date.today().year(); Date yearStart = Date.newInstance(yr, 1, 1); Date yearEnd = Date.newInstance(yr, 12, 31);
    • 4All DML goes in finish() — zero DML in execute().
    Medium Batch Apex

    31. Batch Apex: Mark Inactive Accounts with No Open Opportunities

    Problem #359 · Salesforce Apex Coding Challenge

    Problem Statement

    Write InactiveAccountsBatch that marks Accounts as inactive when they have not been modified in 5 years AND have no open Opportunities.

    Requirements

    • Implement Database.Batchable<SObject>.
    • Overloaded constructors (no-arg defaults to 5 years ago; overload accepts custom DateTime).
    • start(): select Accounts whose LastModifiedDate is older than the cutoff and that have no open Opportunities — use an anti-join subquery, not an Apex loop.
    • execute(): stamp Description = '[INACTIVE ACCOUNT] Reviewed: ' + today; bulk update outside loop.
    • finish(): System.debug the job ID — visible in the Output tab.
    Approach
    • 1An anti-join excludes records whose Id appears in a subquery result — use NOT IN with a subquery on Opportunity, filtered to only open (not-closed) records.
    • 2Use DateTime (not Date) — LastModifiedDate is a DateTime field.
    • 3DateTime.now().addYears(-5) gives the cutoff 5 years ago.
    • 4Both conditions must be met: old LastModifiedDate AND no open opportunities.
    Medium Batch Apex

    32. Batch Apex: Archive Closed Cases Older Than 2 Years

    Problem #360 · Salesforce Apex Coding Challenge

    Problem Statement

    Write ArchiveClosedCasesBatch that stamps the Description of all closed Cases whose ClosedDate is older than 2 years.

    Requirements

    • Implement Database.Batchable<SObject>.
    • No-arg constructor (cutoff = 2 years ago); overloaded constructor accepting custom DateTime.
    • start(): select closed Cases whose ClosedDate is older than the cutoff.
    • execute(): stamp [ARCHIVED]; bulk update outside loop.
    • finish(): System.debug the job ID — visible in the Output tab.
    Approach
    • 1IsClosed is a read-only boolean on Case — use WHERE IsClosed = true.
    • 2ClosedDate is a DateTime field — use DateTime.now().addYears(-2) for the cutoff.
    • 3Stamp each Case inside the loop, then update scope once after.
    • 4Wrap update in try { ... } catch (DmlException e) { System.debug(LoggingLevel.ERROR, e.getMessage()); }
    Medium Batch Apex

    33. Batch Apex: Update Opportunity Probability by Stage Name

    Problem #361 · Salesforce Apex Coding Challenge

    Problem Statement

    Write UpdateOpportunityProbabilityBatch that sets Probability for every Opportunity based on StageName using a static map.

    Stage → Probability

    • Prospecting→10, Qualification→20, Needs Analysis→30, Value Proposition→40
    • Id. Decision Makers→50, Perception Analysis→60, Proposal/Price Quote→70, Negotiation/Review→80
    • Closed Won→100, Closed Lost→0

    Requirements

    • Declare the map as private static final Map<String, Integer>.
    • start(): query only Opportunities whose StageName is a key in STAGE_PROBABILITY_MAP, using the map's key set as the filter.
    • execute(): guard with isEmpty() before DML.
    • finish(): System.debug the job ID — visible in the Output tab.
    Approach
    • 1In start(): Set stages = STAGE_PROBABILITY_MAP.keySet(); — use :stages as a bind variable.
    • 2Skip unknown stages: Integer newProb = STAGE_PROBABILITY_MAP.get(opp.StageName); if (newProb != null) { ... }
    • 3Guard DML: if (!toUpdate.isEmpty()) { try { update toUpdate; } catch (DmlException e) { ... } }
    • 4static final constants avoid magic numbers — define them once, reuse everywhere.
    Medium Batch Apex

    34. Batch Apex: Round-Robin Lead Assignment (Stateful)

    Problem #362 · Salesforce Apex Coding Challenge

    Problem Statement

    Write MassLeadAssignmentBatch that assigns all open non-converted Leads to a list of owners in round-robin order across all batch chunks.

    Requirements

    • Implement Database.Batchable<SObject> and Database.Stateful.
    • Constructor accepts List<Id> ownerIds; throws IllegalArgumentException if null/empty.
    • Stateful assignmentIndex = 0 persists across chunks.
    • execute(): Math.mod(assignmentIndex, ownerCount); bulk update outside loop.
    • finish(): System.debug total assigned — visible in the Output tab.
    Approach
    • 1Round-robin: l.OwnerId = ownerIds[Math.mod(assignmentIndex, ownerIds.size())]; assignmentIndex++;
    • 2Database.Stateful is required — assignmentIndex must persist across separate execute() calls.
    • 3Validate: if (ownerIds == null || ownerIds.isEmpty()) throw new IllegalArgumentException(...);
    • 4Cache ownerIds.size() before the loop: Integer ownerCount = ownerIds.size();
    Medium Batch Apex

    35. Batch Apex: Clean Duplicate Contacts by Account and Email

    Problem #363 · Salesforce Apex Coding Challenge

    Problem Statement

    Write CleanDuplicateContactsBatch that removes duplicate Contacts within the same Account by the composite key AccountId + Email (case-insensitive). Keep the oldest Contact per group; delete the rest.

    Requirements

    • Implement Database.Batchable<SObject>.
    • start(): query only Contacts that have both an AccountId and an Email, sorted so records sharing the same Account and Email are adjacent and the oldest one in each group comes first.
    • execute(): group by AccountId + '|' + email.toLowerCase(); keep index 0; delete the rest with Database.delete(list, false).
    • finish(): System.debug the job ID — visible in the Output tab.
    Approach
    • 1Composite key: String key = c.AccountId + '|' + c.Email.toLowerCase();
    • 2Sort the query so records sharing an Account+Email group are adjacent and the oldest one lands first — that makes index 0 of each group the keeper.
    • 3Database.delete(toDelete, false): allOrNone=false. Inspect each DeleteResult.isSuccess().
    • 4Contacts from different Accounts with the same Email are NOT duplicates — the AccountId prefix keeps them separate.
    Medium Batch Apex

    36. Batch Apex: Chain a Follow-Up Batch From finish()

    Problem #385 · Salesforce Apex Coding Challenge

    Problem Statement

    Write two Batch Apex classes that run as a two-step pipeline:

    1. RecalculateAccountRevenueBatch — recomputes each Account's AnnualRevenue as the sum of its Closed Won Opportunity Amounts.
    2. AccountTierBatch — sets each Account's Rating to Hot/Warm/Cold based on the (now up-to-date) AnnualRevenue.

    The first batch's finish() method should chain the second batch — but only if the first batch actually changed something.

    Requirements

    • RecalculateAccountRevenueBatch implements Database.Batchable<SObject>, Database.Stateful so it can track how many Accounts were updated across all batch chunks.
    • Aggregate the Closed Won total per Account with one GROUP BY query per execute() call — never query per record.
    • In finish(), call Database.executeBatch(new AccountTierBatch(), 200) only when accountsUpdated > 0.

    Best Practices

    • Chaining unconditionally wastes a full batch job (and its own governor-limit budget) when there was nothing to do — always guard the chain with a real condition.
    • Database.Stateful is required to keep a running counter across chunks — without it, every execute() call gets a fresh instance.
    • Wrap DML in try/catch(DmlException).
    Approach
    • 1An AggregateResult row exposes your SUM() alias via get('yourAlias') — cast it to Decimal. Filter to only Closed Won Opportunities before aggregating, and group so each Account gets its own total.
    • 2Compare the aggregated total against the Account's current AnnualRevenue before adding it to the update list — skip Accounts that would not actually change.
    • 3Database.Stateful lets accountsUpdated survive across every execute() call in the job — a plain Batchable class resets instance state each chunk.
    • 4Database.executeBatch(new AccountTierBatch(), 200) inside finish() is exactly how one batch chains into another.
    Hard Batch Apex

    37. Batch Apex: Process a Fixed Account List Using a Custom Iterable

    Problem #386 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a Batch Apex class SpecificAccountsBatch that processes only a caller-supplied list of Account Ids (passed into the constructor), stamping each one's Description to mark it as reviewed.

    Instead of returning a Database.QueryLocator from start(), implement it using a custom Iterable<SObject>AccountIterable and AccountIterator — to practice the pattern used when a batch's scope isn't a plain SOQL query result.

    Requirements

    • SpecificAccountsBatch(List<Id> accountIds) constructor stores the Ids.
    • start() returns Iterable<SObject>, backed by an AccountIterable wrapping the queried Accounts.
    • AccountIterable implements Iterable<SObject> and returns an AccountIterator.
    • AccountIterator implements Iterator<SObject> with hasNext() and next().
    • execute() sets Description = 'Reviewed via curated batch run'.

    Best Practices

    • A custom Iterable is the right tool when the batch's working set comes from more than "run this one SOQL query" — e.g. a pre-curated list, records merged from multiple queries, or synthetic data. For a plain SOQL scan, Database.QueryLocator is simpler and should still be preferred.
    • Still do the actual record query once inside start(), not once per iterator step — the iterator just walks an already-fetched, in-memory list here.
    • Bulk DML outside the loop, wrapped in try/catch(DmlException).
    Approach
    • 1start() can return Iterable instead of Database.QueryLocator — Salesforce batch execution accepts either.
    • 2AccountIterable just needs to hold the queried List and hand back a fresh AccountIterator from iterator().
    • 3AccountIterator needs an index field: hasNext() checks index < list.size(); next() returns list[index] then increments index.
    • 4Inside execute(), each SObject in scope must be cast back to Account before you can read/set its fields.
    Medium Batch Apex

    38. Batch Apex: Log Failed Records with Database.Stateful

    Problem #387 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a Batch Apex class UpdateOpportunityDiscountBatch that sets Discount__c on every open Opportunity — 10 if Amount > 100000, otherwise 5.

    Any record that fails to update must be captured (not silently dropped) and written to a custom object Batch_Error_Log__c once the job finishes.

    Requirements

    • Implement Database.Batchable<SObject>, Database.Stateful — a running List<String> failureMessages must survive across every chunk.
    • Update with Database.update(scope, false) (partial success) instead of a plain update scope; — inspect each Database.SaveResult for failures and append a descriptive message per error.
    • finish(): if any failures were recorded, bulk-insert one Batch_Error_Log__c per failure message (fields: Message__c, Batch_Job__c).

    Best Practices

    • allOrNone = false means one bad record in a chunk doesn't roll back every other record in that same chunk — essential for large batch jobs processing imperfect data.
    • Database.Stateful is required here too — without it, failureMessages would reset to empty on every chunk and finish() would only ever see the last chunk's failures.
    • Insert the error log records once in finish(), not per chunk — keeps the log tidy and avoids DML inside execute() beyond the primary update.
    Approach
    • 1Database.update(scope, false) returns a List — one entry per input record, in the same order as scope.
    • 2results[i].isSuccess() tells you if scope[i] succeeded; if not, loop results[i].getErrors() for the actual DmlException-equivalent details.
    • 3Build the List once in finish() from the accumulated failureMessages, then insert it in a single DML call.
    • 4Without Database.Stateful, failureMessages would be a brand-new empty list at the start of every execute() call — you would lose everything from earlier chunks.
    Hard Batch Apex

    39. Batch Apex: Sync Accounts to an External System via HTTP Callout

    Problem #388 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a Batch Apex class SyncAccountsCalloutBatch that pushes every Account with Sync_Status__c = 'Pending' to an external CRM via a single bulk HTTP callout per chunk, then stamps each Account as Synced or Failed based on the response.

    Requirements

    • Implement Database.Batchable<SObject>, Database.AllowsCallouts — the second interface is required for any HTTP callout inside a batch job.
    • execute(): build one JSON payload covering the whole scope and send one callout — never one callout per record.
    • On a 200 response, mark every Account in the chunk Synced; otherwise (bad status code, or a caught CalloutException) mark them Failed.
    • Use a Named Credential-style callout endpoint (callout:External_CRM/...), never a hardcoded URL.

    Best Practices

    • Batch Apex enforces its own 100-callout limit per execute() transaction — one callout per chunk (not per record) is what keeps that safe at any batch size.
    • Because each callout consumes real time, choose a small batch size when calling Database.executeBatch(new SyncAccountsCalloutBatch(), 10) rather than the default 200, to stay within callout timeout limits per transaction.
    • Named Credentials (callout:External_CRM) keep the endpoint and auth out of source code — never hardcode the raw URL or credentials.
    • Always handle CalloutException — a network failure must not crash the whole batch chunk.
    Approach
    • 1Database.AllowsCallouts must be added alongside Database.Batchable in the implements clause — without it, any callout throws immediately.
    • 2Build a List> of the scope's fields, then JSON.serialize(...) it as the single request body.
    • 3req.setEndpoint('callout:External_CRM/...') — the callout: prefix routes through a Named Credential instead of a raw URL.
    • 4Wrap Http().send(req) in try/catch(CalloutException) so a network failure still lets you mark the chunk Failed instead of aborting.
    Easy Batch Apex

    40. Batch Apex: Flag Leads With No Activity in 90 Days

    Problem #389 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a Batch Apex class FlagStaleLeadsBatch that sets Status = 'Stale' on every non-converted Lead whose LastActivityDate is more than 90 days old (or was never set at all).

    Requirements

    • Implement Database.Batchable<SObject> with all three methods.
    • start(): compute the 90-day cutoff Date once, bind it into the query.
    • execute(): set Status = 'Stale' in the loop, then a single bulk update outside it.

    Best Practices

    • Compute the cutoff Date once per start() call, not inline inside the SOQL string.
    • Filter out Leads that have already converted so the batch doesn't waste chunks on records that no longer need attention.
    • Wrap the update in try/catch(DmlException).
    Approach
    • 1Compute a Date for "90 days ago" before you build the query string, so you can bind it into the SOQL with a colon-prefixed variable reference.
    • 2A Lead that has never had any activity logged won't have a last-activity value at all — your staleness check needs to treat that missing case as stale too, not just an old one.
    • 3Mutate every Lead in the for loop, then call update scope once after the loop ends.
    • 4Leads that have already converted shouldn't be touched by this batch — keep them out of the query scope entirely.
    Medium Batch Apex

    41. Batch Apex: Update Parent and Child Records in One Pass

    Problem #394 · Salesforce Apex Coding Challenge

    Problem Statement

    Write a Batch Apex class CloseStaleOpportunitiesBatch that closes (as Closed Lost) every open Opportunity whose CloseDate is more than 180 days in the past, and stamps each affected Opportunity's parent Account with a note that it had a stale deal auto-closed.

    Requirements

    • start(): query open Opportunities with CloseDate older than a 180-day cutoff.
    • execute(): build the Opportunity updates and collect the related AccountIds in the same loop; run the Opportunity update.
    • Then, in a separate step, build and run the Account update — never put two different SObject types in the same list/DML call.

    Best Practices

    • Apex DML statements operate on one SObject type at a time — mixing Opportunity and Account records into a single list and calling update on it throws a runtime error, so this always needs two separate bulk DML calls.
    • Collect the related Account Ids into a Set<Id> in the same pass you build the Opportunity updates — don't re-loop or re-query for something you already have on hand.
    • Wrap both DML calls in their own try/catch(DmlException) — a failure updating Accounts shouldn't be conflated with (or silently hide) a failure updating Opportunities.
    Approach
    • 1Build a Set accountIds inside the same for loop where you build the Opportunity update list — no need for a second pass over scope.
    • 2update oppsToUpdate; and update accountsToUpdate; must be two separate DML statements — Apex cannot update mixed SObject types in one call.
    • 3Guard the Account update with if (!accountIds.isEmpty()) so an empty chunk doesn't trigger a pointless DML call.
    • 4Give each DML call its own try/catch(DmlException) so an Account-side failure is distinguishable from an Opportunity-side one in the debug logs.
    Medium Batch Apex

    42. Scenario: Downgrade Inactive Premium Accounts to Standard Tier

    Problem #441 · Salesforce Apex Coding Challenge

    Business Scenario

    Premium-tier support is expensive to staff. Any Account still marked Tier__c = 'Premium' that hasn't had any activity in 180 days should be automatically downgraded to 'Standard'.

    Requirements

    • Only targets Accounts currently on the Premium tier.
    • Includes Accounts whose last activity is stale (more than 180 days old), and Accounts that have never had any activity at all.
    • Sets Tier__c = 'Standard'.
    Approach
    • 1Date cutoff = Date.today().addDays(-180); computed once, before the query string.
    • 2Remember the never-active case — a null last-activity date should qualify too, not just an old one.
    • 3Scope the query to the Premium tier so already-Standard Accounts are never touched.
    Hard Batch Apex

    43. Scenario: Reassign Accounts to a New Owner with an Audit Trail

    Problem #444 · Salesforce Apex Coding Challenge

    Business Scenario

    A departing sales rep's entire book of Accounts needs to move to a replacement rep, with a permanent audit record of the change for compliance. Write a Batch Apex class ReassignAccountOwnerBatch that moves every Account owned by one User to another, logging each change to a custom object Account_Ownership_Log__c.

    Requirements

    • Constructor takes fromOwnerId and toOwnerId.
    • start() queries every Account currently owned by fromOwnerId.
    • execute() reassigns OwnerId and inserts one Account_Ownership_Log__c per Account, capturing the old owner, new owner, and timestamp.

    Best Practices

    • Capture the old owner value before building the update — once you reassign OwnerId in memory, the original value is gone unless you record it first.
    • Two separate DML calls (Account update, log insert) since they're different SObject types — never mix them in one list.
    Approach
    • 1Read a.OwnerId (the CURRENT value, from the query) into the log record before building the Account update — otherwise you lose the "old owner" once you reassign it.
    • 2System.now() gives a DateTime for the Changed_At__c timestamp.
    • 3update toUpdate; and insert logs; must be two separate DML statements.
    Expert Batch Apex

    44. Scenario: Governor-Safe Data Backfill With Resumable Checkpointing

    Problem #450 · Salesforce Apex Coding Challenge

    Business Scenario

    A one-time data backfill needs to compute Territory_Code__c for every Account missing it, derived from Region__c. Given the volume, some records may fail (validation rules, field-length limits) — track failures, log them, and automatically retry by re-launching the batch (since a fresh start() query will only pick up Accounts still missing Territory_Code__c, which naturally excludes everything that already succeeded).

    Requirements

    • Implements Database.Batchable<SObject>, Database.Stateful to track running success/failure counts across all chunks.
    • Derives Territory_Code__c as the first 3 letters of Region__c, uppercased (or 'UNK' if Region__c is blank).
    • Uses Database.update(scope, false) so one bad record doesn't block the rest of the chunk; records every failure's message.
    • In finish(), if there were any failures: log them all to Batch_Error_Log__c, then re-launch the batch to retry — since start()'s query only ever selects Accounts still missing Territory_Code__c, the retry naturally covers just the leftover failures.

    Best Practices

    • Designing start()'s query to be self-limiting (only ever selects remaining incomplete work) is what makes "re-launch on failure" safe — it can never re-process already-successful records or loop forever once everything succeeds.
    • Database.Stateful is required to keep recordsProcessed/ recordsFailed accurate across every chunk of a potentially large job.
    Approach
    • 1String.left(3) gets the first 3 characters; guard Region__c != null before calling it.
    • 2Database.update(scope, false) returns a List — check isSuccess() per record, same order as scope.
    • 3Because start() always re-selects "still missing Territory_Code__c", re-launching in finish() automatically retries only what failed — no separate failure list needs to be passed to the next run.
    • 4Only re-launch when recordsFailed > 0 — a fully successful run should not schedule another batch.
    Master Batch Apex

    45. Scenario: Nightly Account Tiering Pipeline — Batch Computes, Queueable Notifies

    Problem #452 · Salesforce Apex Coding Challenge

    Business Scenario

    Every night, recompute every Account's revenue-based tier (Standard / Gold / Platinum). Any Account that newly reaches Platinum should generate a follow-up Task for its owner — but the notification step should only look at the Accounts that actually changed, not re-scan the whole org.

    Requirements

    • AccountTieringPipelineBatch (Database.Batchable<SObject>, Database.Stateful): recomputes Tier__c for every Account (Platinum >= 10,000,000 revenue, Gold >= 1,000,000, else Standard), tracking the Ids of Accounts that newly became Platinum this run.
    • In finish(), if any Accounts newly reached Platinum, enqueue PlatinumUpgradeNotifyQueueable with just those Ids.
    • PlatinumUpgradeNotifyQueueable re-queries those Accounts and creates one Task per Account for its owner.

    Best Practices

    • Chaining a Queueable from a batch's finish() (rather than another Batch) is the right choice here — the follow-up work (a handful of Task inserts) is small and doesn't need its own governor-limit-scoped chunks.
    • Passing only the changed Account Ids to the Queueable — not re-deriving "who's Platinum" from scratch — keeps the notification step's query scope minimal.
    • Only update an Account when its tier actually changed (newTier != a.Tier__c), avoiding a no-op DML write on every single Account every night.
    Approach
    • 1Compute newTier with a nested ternary (or IF-style chain), then compare it against the Account's current Tier__c before adding it to the update list.
    • 2Only add to upgradedAccountIds when the account is newly Platinum this run — i.e. newTier == 'Platinum' AND the old Tier__c was not already Platinum.
    • 3System.enqueueJob(new PlatinumUpgradeNotifyQueueable(upgradedAccountIds)) in finish() hands off just the changed Ids.
    • 4The Queueable must re-query fresh Account data by Id rather than trying to reuse anything from the batch — Queueable constructors only carry simple serializable state like a Set.
    Master Batch Apex

    46. Scenario: Build a Configurable Data-Quality Rule Runner Using Dynamic Apex

    Problem #454 · Salesforce Apex Coding Challenge

    Business Scenario

    Data governance wants a reusable batch that can check any set of field-level rules against Accounts — e.g. "Website must look like a URL", "Phone must be 10+ digits" — without writing a new batch class every time a rule is added. Build it around a caller-supplied list of rules, evaluated dynamically via generic field access.

    Requirements

    • DataQualityRule: a simple data class holding fieldName and requiredPattern (a regex the field's value must match).
    • DataQualityRuleRunnerBatch constructed with List<DataQualityRule> rules.
    • start() dynamically builds its SELECT clause from every rule's fieldName (plus Id), so the batch works for whatever fields the caller's rules reference — no hardcoded field list.
    • execute(): for each Account and each rule, read the field's value via SObject.get(fieldName), and if it doesn't match the rule's requiredPattern, insert a Data_Quality_Violation__c recording the record, field name, and offending value.

    Best Practices

    • Building the SELECT clause dynamically from the rules list is what makes this batch reusable — adding a new field-level rule never requires touching this class again.
    • SObject.get(fieldName) + Pattern.matches() is the standard pattern for evaluating a field generically without knowing its name at compile time.
    • Guard against a null field value by converting to an empty string before pattern matching, rather than letting a null reach Pattern.matches().
    Approach
    • 1Set fieldNames = new Set{'Id'}; then add each rule's fieldName, avoids duplicate columns if two rules target the same field.
    • 2String.join(new List(fieldNames), ', ') turns the Set into a comma-separated SELECT clause.
    • 3record.get(rule.fieldName) reads any field generically by name — convert a null result to '' before pattern matching.
    • 4Pattern.matches(rule.requiredPattern, stringValue) returns false for anything that doesn't fully match the regex.
    Easy Batch Apex

    47. Batch Apex: Monthly Customer Status Refresh

    Problem #504 · Salesforce Apex Coding Challenge

    Business Scenario

    Sales operations wants Account.Customer_Status__c refreshed once a month so reps can trust it reflects real recent engagement, not whatever was set at Account creation.

    Requirements

    • Write MonthlyCustomerStatusBatch implementing Database.Batchable<SObject>.
    • An Account is Active if LastActivityDate is within the last INACTIVITY_DAYS (90) days; otherwise it is Dormant.
    • Only add an Account to the update list when its current Customer_Status__c actually differs from the computed value.
    • execute() must issue a single bulk update call, never DML per record.

    Best Practices

    • A named constant (INACTIVITY_DAYS) instead of a bare 90 keeps the rule self-documenting and easy to tune later.
    • Skipping Accounts whose status wouldn't change avoids no-op DML rows and keeps audit/history trails clean.
    • Wrap the bulk update in try/catch(DmlException) so one bad chunk doesn't surface an unhandled exception in the job's history.
    Approach
    • 1Compute the cutoff Date once per execute() call: Date.today().addDays(-INACTIVITY_DAYS).
    • 2An Account is Active when LastActivityDate is not null and >= cutoff; otherwise Dormant.
    • 3Compare against the current Customer_Status__c before adding to the update list — skip no-ops.
    • 4Use a single update toUpdate; call outside the for loop, wrapped in try/catch(DmlException).
    Easy Batch Apex

    48. Batch Apex: Delete Expired Campaign Members

    Problem #505 · Salesforce Apex Coding Challenge

    Business Scenario

    Marketing wants stale, never-responded CampaignMember records purged periodically so Campaign response-rate reports stay meaningful and lists don't grow unbounded.

    Requirements

    • Write DeleteExpiredCampaignMembersBatch implementing Database.Batchable<SObject>.
    • Target only CampaignMembers created more than EXPIRATION_DAYS (180) days ago that never responded (HasResponded = false).
    • execute() must delete the whole scope in a single bulk DML call.

    Best Practices

    • Filtering in start()'s query (rather than in Apex after querying everything) keeps each chunk small and avoids wasting governor limits on records that won't be touched.
    • A single delete scope; call handles the whole chunk in one DML statement — never delete inside a loop.
    • Wrap the delete in try/catch(DmlException) so a locked record in one chunk doesn't silently kill the whole job.
    Approach
    • 1Compute the cutoff Date from EXPIRATION_DAYS, then filter CreatedDate < :cutoff in the query.
    • 2Also filter HasResponded = false directly in the SOQL — don't delete members who engaged.
    • 3delete scope; is valid Apex on a List — no loop needed.
    • 4Wrap the delete in try/catch(DmlException) to handle any per-chunk failures gracefully.
    Medium Batch Apex

    49. Batch Apex: Annual Product Price Adjustment

    Problem #506 · Salesforce Apex Coding Challenge

    Business Scenario

    Finance wants product list prices adjusted once a year by a per-product inflation rate stored on a custom field, instead of manually editing every Product2 record.

    Requirements

    • Write AnnualPriceAdjustmentBatch implementing Database.Batchable<SObject>, Database.Stateful to track how many products were adjusted across all chunks.
    • Only process active Products that have a non-null Annual_Adjustment_Percent__c.
    • New price = Standard_Price__c * (1 + Annual_Adjustment_Percent__c / 100), rounded to 2 decimal places.
    • Skip Products with a null Standard_Price__c instead of erroring.

    Best Practices

    • Database.Stateful is required for productsAdjusted to survive across every execute() chunk — without it the counter resets to 0 each time.
    • Filtering Annual_Adjustment_Percent__c != null in the query keeps the batch from wasting chunks on Products that would never change.
    • setScale(2) avoids floating-point noise accumulating in currency fields.
    • All DML happens once, outside the per-record loop.
    Approach
    • 1Filter IsActive = true AND Annual_Adjustment_Percent__c != null directly in the start() query.
    • 2factor = 1 + (Annual_Adjustment_Percent__c / 100); newPrice = (Standard_Price__c * factor).setScale(2).
    • 3Skip records with a null Standard_Price__c with a simple continue inside the loop.
    • 4Database.Stateful is what lets productsAdjusted accumulate across chunks instead of resetting.
    Medium Batch Apex

    50. Batch Apex: Realign Opportunity Forecast Categories

    Problem #507 · Salesforce Apex Coding Challenge

    Business Scenario

    Sales leadership doesn't trust the pipeline forecast because reps rarely update ForecastCategoryName manually. A nightly batch should realign it with objective signals: stage, close date, and how long an Opportunity has sat without progressing.

    Requirements

    • Write RealignForecastCategoryBatch implementing Database.Batchable<SObject>.
    • Only process open Opportunities (IsClosed = false).
    • An Opportunity is Omitted if its CloseDate has passed, or it has been stalled (no stage change) for at least STALLED_DAYS_THRESHOLD (45) days.
    • Otherwise, Closed Won stage maps to Closed; Negotiation/Review or Proposal/Price Quote map to Best Case; anything else keeps its current category.
    • Only update Opportunities whose computed category actually differs from the current value.

    Best Practices

    • Deriving the category from real fields (stage, dates) rather than trusting manual rep input produces a forecast leadership can rely on.
    • Comparing against the existing value before adding to toUpdate avoids needless DML rows and preserves field history for genuine changes only.
    • All date math and comparisons happen per-record in memory; the only DML is one bulk update outside the loop.
    Approach
    • 1Use LastStageChangeDate.daysBetween(today) >= STALLED_DAYS_THRESHOLD to detect a stalled deal.
    • 2Check "past close date" and "stalled" first — both force the category to Omitted regardless of stage.
    • 3Map Closed Won -> Closed, and Negotiation/Review or Proposal/Price Quote -> Best Case.
    • 4Only add an Opportunity to toUpdate when the computed newCategory differs from o.ForecastCategoryName.
    Hard Batch Apex

    51. Batch Apex: Reassign Open Cases to Active Agents by Capacity

    Problem #508 · Salesforce Apex Coding Challenge

    Business Scenario

    When an agent goes inactive (leaves the team, is deactivated), their open Cases are orphaned. Support operations needs a batch that reassigns those Cases to active agents who still have spare capacity, instead of a blind round-robin.

    Requirements

    • Write ReassignCasesToActiveAgentsBatch implementing Database.Batchable<SObject>, Database.Stateful.
    • Target only open Cases owned by an inactive agent (Owner.IsActive = false).
    • Build each active agent's current open-Case count once (via an aggregate query), and only consider agents under MAX_OPEN_CASES_PER_AGENT (15) as reassignment targets.
    • Distribute reassigned Cases across eligible agents rather than dumping them all on one agent, and stop assigning once no eligible agent has spare capacity.

    Best Practices

    • Basing eligibility on User.IsActive plus a real open-Case count (not just "any active user") prevents dumping work onto an agent who is already overloaded.
    • The capacity map is built once via loadAgentCapacity() and reused/mutated in memory across the whole job — no per-record SOQL.
    • Database.Stateful keeps casesReassigned (and the capacity map's cumulative state via the cursor) consistent across chunks.
    • Bulk DML: exactly one update call per chunk.
    Approach
    • 1The start() query must filter on Owner.IsActive = false — those are the orphaned Cases needing a new owner.
    • 2Aggregate current open-Case load per active agent with SELECT OwnerId, COUNT(Id) ... GROUP BY OwnerId — one query, not per-agent SOQL.
    • 3Only agents with count < MAX_OPEN_CASES_PER_AGENT belong in eligibleAgentIds.
    • 4Database.Stateful is required for openCaseCountByAgent and casesReassigned to persist correctly across chunks.
    Hard Batch Apex

    52. Batch Apex: Generate Yearly Invoices for Active Contracts

    Problem #509 · Salesforce Apex Coding Challenge

    Business Scenario

    Billing needs one Invoice__c generated per year for every Activated Contract. This runs as a scheduled yearly batch job across the whole Contract population.

    Requirements

    • Write GenerateYearlyInvoicesBatch implementing Database.Batchable<SObject>, Database.Stateful, tracking how many invoices were created.
    • Provide a no-arg constructor (defaults invoiceYear to the current year) and an overloaded constructor accepting an explicit year, for testability and back-filling.
    • Only process Contracts with Status = 'Activated'.
    • Idempotency: before inserting, check for an existing Invoice__c for the same Contract and year, and skip Contracts that already have one.
    • Create Invoice__c with Contract__c, Account__c, Invoice_Year__c, Invoice_Date__c, and Status__c = 'Draft'.

    Best Practices

    • The idempotency check (querying existing invoices for the chunk's Contract Ids/year before inserting) makes the batch safe to re-run without creating duplicate invoices.
    • One query for existing invoices per chunk, one bulk insert — never per-record SOQL or DML.
    • Database.Stateful is required to keep invoicesCreated accurate across chunks.
    Approach
    • 1Two constructors: a no-arg one defaulting invoiceYear to Date.today().year(), and one taking an explicit Integer year.
    • 2Query Invoice__c WHERE Contract__c IN :contractIds AND Invoice_Year__c = :invoiceYear to find Contracts already invoiced this year.
    • 3Skip a Contract entirely if its Id is already in the alreadyInvoiced set — this is what makes the batch idempotent on re-run.
    • 4insert toInsert; once outside the loop, wrapped in try/catch(DmlException).
    Hard Batch Apex

    53. Batch Apex: Internal ERP Data Reconciliation

    Problem #510 · Salesforce Apex Coding Challenge

    Business Scenario

    Finance already loads a nightly extract of ERP account balances into a custom staging object, ERP_Sync_Log__c. This batch performs a purely internal reconciliation — comparing the ERP-side balance already sitting in that object against the corresponding Salesforce Account.AnnualRevenue — and flags mismatches for manual review. There is no external HTTP callout here; the ERP data has already landed.

    Requirements

    • Write ErpReconciliationBatch implementing Database.Batchable<SObject>, Database.Stateful, counting how many discrepancies were flagged.
    • Process only unreconciled ERP_Sync_Log__c records (Reconciled__c = false).
    • Compare ERP_Balance__c against the related Account.AnnualRevenue; flag Out_Of_Sync__c = true when the absolute difference exceeds AMOUNT_TOLERANCE (0.01).
    • Mark every processed log entry Reconciled__c = true with today's Reconciled_Date__c, regardless of whether it was in sync.

    Best Practices

    • A small AMOUNT_TOLERANCE constant avoids false-positive discrepancies caused by floating-point/rounding noise.
    • Traversing the parent relationship (Account__r.AnnualRevenue) in the same query avoids a second SOQL per chunk.
    • Database.Stateful keeps the discrepanciesFlagged total accurate across the whole job for the final summary in finish().
    • One bulk update per chunk, wrapped in try/catch(DmlException).
    Approach
    • 1Query Account__r.AnnualRevenue via the relationship in the same SOQL — no second query or callout needed.
    • 2Use Math.abs(erpValue - sfValue) > AMOUNT_TOLERANCE to decide Out_Of_Sync__c.
    • 3Every processed record gets Reconciled__c = true and a Reconciled_Date__c stamp, in sync or not.
    • 4This batch never performs an HTTP callout — the ERP data is already staged in ERP_Sync_Log__c by a separate nightly extract.
    Expert Batch Apex

    54. Batch Apex: Nightly Product Catalog Import From Staging

    Problem #511 · Salesforce Apex Coding Challenge

    Business Scenario

    Every night, an external file-load process drops rows into a staging custom object, Product_Import_Staging__c. Batch Apex cannot itself receive a file, so this batch picks up unprocessed staging rows and upserts them into Product2.

    Requirements

    • Write ProductCatalogImportBatch implementing Database.Batchable<SObject>, Database.Stateful, tracking created/updated/failed counts.
    • Process only unprocessed staging rows (Processed__c = false).
    • Rows missing ProductCode__c or Name__c are invalid: mark them processed with an Import_Error__c reason and skip upserting them.
    • Match existing Products by ProductCode; use upsert ... ProductCode (upsert on an external-id-like field) to create-or-update in a single DML statement.
    • Mark every staging row Processed__c = true once handled, valid or not.

    Best Practices

    • Looking up existing Products by code with one bulk query (WHERE ProductCode IN :codes) avoids a SOQL call per staging row.
    • upsert toUpsert ProductCode; collapses "does this already exist" branching into a single DML statement instead of separate insert/update lists.
    • Invalid rows are marked processed (with a reason) rather than left in an infinite retry loop on the next nightly run.
    • Database.Stateful keeps the create/update/fail counters accurate for the final finish() summary.
    Approach
    • 1Batch Apex cannot read a file directly — model this as processing rows an external load process already dropped into Product_Import_Staging__c.
    • 2Bulk-query Product2 WHERE ProductCode IN :codes once per chunk to build a Map lookup.
    • 3upsert toUpsert ProductCode; performs create-or-update in a single DML statement keyed on the ProductCode field.
    • 4Always mark every staging row Processed__c = true, whether it succeeded or failed validation — otherwise it gets reprocessed forever.
    Expert Batch Apex

    55. Batch Apex: Bulk Recalculate Customer Loyalty Points

    Problem #512 · Salesforce Apex Coding Challenge

    Business Scenario

    Loyalty points for enrolled Contacts have drifted from actual purchase history. This batch recalculates every loyalty member's points from their lifetime Activated Order spend, and upgrades their tier once they cross a points threshold.

    Requirements

    • Write RecalculateLoyaltyPointsBatch implementing Database.Batchable<SObject>, Database.Stateful, counting tier upgrades.
    • Only process Contacts flagged Loyalty_Member__c = true.
    • Aggregate each Contact's total spend from Activated Orders with a single GROUP BY query per chunk — never per-Contact SOQL.
    • Points = spend * POINTS_PER_DOLLAR (2); tier is Platinum once points reach TIER_UPGRADE_THRESHOLD (5000), otherwise Standard.
    • Only update Contacts whose points or tier actually changed, and only increment the upgrade counter on a genuine Standard → Platinum transition.

    Best Practices

    • One aggregate SOQL per chunk keyed by ContactId keeps spend calculation governor-safe regardless of how many Orders exist per Contact.
    • Comparing old vs. new points/tier before adding to toUpdate avoids no-op DML on Contacts whose recalculated values didn't change.
    • Database.Stateful is required for contactsUpgraded to accumulate correctly across every chunk in the job.
    Approach
    • 1Aggregate each Contact's Activated Order spend in a single grouped query keyed by ContactId — resolve every Contact's total in one pass rather than querying per Contact.
    • 2newPoints = (spend * POINTS_PER_DOLLAR).setScale(0); newTier = newPoints >= TIER_UPGRADE_THRESHOLD ? 'Platinum' : 'Standard'.
    • 3Only increment contactsUpgraded when the tier is newly Platinum (previous tier was not already Platinum).
    • 4Compare both Loyalty_Points__c and Loyalty_Tier__c against the computed values before adding to toUpdate.
    Expert Batch Apex

    56. Batch Apex: Recalculate Fulfillment Status Across Millions of Orders

    Problem #513 · Salesforce Apex Coding Challenge

    Business Scenario

    A high-volume distributor has millions of Order records. Fulfillment status must reflect real shipping progress on the underlying OrderItem lines, but nothing keeps it in sync in real time. This nightly batch recalculates it at scale.

    Requirements

    • Write OrderFulfillmentStatusBatch implementing Database.Batchable<SObject>, Database.Stateful, counting Orders updated.
    • start() must use a narrow, indexed filter (Status IN ('Draft','Activated')) — never a full unfiltered scan of the Order table at this volume.
    • Per chunk, aggregate total OrderItem count and shipped OrderItem count per Order with two GROUP BY queries — never per-Order SOQL.
    • All items shipped → Activated; some but not all shipped → Draft (partially fulfilled); otherwise leave the status unchanged.

    Best Practices

    • At multi-million-row scale, the start() filter is what makes the QueryLocator viable at all — an unfiltered query wastes the whole job on records that can never change.
    • Two bulk aggregate queries per chunk (total vs. shipped) scale independently of how many line items a single Order has.
    • Only updating Orders whose computed status differs from the current one minimizes DML rows across a run touching millions of records.
    • Database.Stateful keeps ordersUpdated accurate for the final summary across every chunk.
    Approach
    • 1Filtering Status IN ('Draft', 'Activated') in start() is what keeps this batch viable across millions of Orders.
    • 2Two GROUP BY OrderId aggregate queries per chunk: one for total OrderItem count, one for shipped (Shipped_Date__c != null) count.
    • 3total > 0 && shipped == total means fully shipped -> Activated; shipped > 0 && shipped < total means partially shipped -> Draft.
    • 4Only add an Order to toUpdate when the computed newStatus differs from its current Status.
    Expert Batch Apex

    57. Batch Apex: Generate Enterprise-Wide Sales Summary Reports

    Problem #514 · Salesforce Apex Coding Challenge

    Business Scenario

    Executives want one consolidated Enterprise_Report__c row per sales owner summarizing this year's Closed Won revenue and deal count, aggregated across the entire Opportunity table.

    Requirements

    • Write EnterpriseReportBatch implementing Database.Batchable<SObject>, Database.Stateful, accumulating per-owner revenue and deal-count totals across every chunk.
    • start() filters to Closed Won Opportunities closed in the current reportYear.
    • execute() only accumulates in-memory totals into the stateful maps — it must not perform any DML per chunk.
    • finish() converts the accumulated maps into one Enterprise_Report__c row per owner and inserts them all in a single bulk DML call.

    Best Practices

    • Deferring all DML to finish() is correct here because the summary rows depend on totals accumulated across the entire job, not any single chunk.
    • Database.Stateful is what allows revenueByRegionOwner and countByRegionOwner to keep accumulating correctly across every execute() call instead of resetting each time.
    • The final insert in finish() is still a single bulk DML statement, not one insert per owner.
    Approach
    • 1CALENDAR_YEAR(CloseDate) = reportYear lets you filter Opportunities closed in a specific year inside the query string.
    • 2execute() should only mutate the stateful maps in memory — never issue DML per chunk here.
    • 3In finish(), loop over revenueByRegionOwner.keySet() to build one Enterprise_Report__c per owner.
    • 4Database.Stateful is required for the maps to keep accumulating totals across every execute() call.
    Master Batch Apex

    58. Batch Apex: Renew or Expire Global Subscriptions Across Currencies

    Problem #515 · Salesforce Apex Coding Challenge

    Business Scenario

    A global SaaS company tracks customer subscriptions in a multi-currency org via Subscription__c. Every day, due subscriptions must either auto-renew (advance the renewal date one month) or expire, across every currency and region, with per-record failures isolated so a handful of bad rows never abort the whole run.

    Requirements

    • Write GlobalSubscriptionRenewalBatch implementing Database.Batchable<SObject>, Database.Stateful, tracking renewed/expired/failed counts.
    • Target Active subscriptions whose Renewal_Date__c is due (<= TODAY) — this must work correctly regardless of the org's multi-currency setup.
    • A subscription with Auto_Renew__c = true advances Renewal_Date__c by one month and stays Active; otherwise it becomes Expired.
    • Validate each subscription has a positive Amount__c before renewing — invalid records must not block the rest of the chunk.
    • Use Database.update(toUpdate, false) (partial success) so a DML failure on one record doesn't roll back the whole chunk, and record per-record failures.

    Best Practices

    • Database.update(list, false) with a per-result check is exactly the pattern for "some records may legitimately fail — keep processing the rest," which matters at global scale where a single bad currency conversion or missing field shouldn't halt the whole batch.
    • Validating business rules (positive amount) before attempting DML, via a custom exception, keeps the failure reason meaningful instead of a generic DML error.
    • Database.Stateful keeps the renewed/expired/failed counters — and the failure log — accurate across every chunk in the job.
    • Currency amounts in a multi-currency org are still ordinary Decimal values on the record; no special handling is needed beyond respecting CurrencyIsoCode when querying/reporting.
    Approach
    • 1Query filter: Status__c = 'Active' AND Renewal_Date__c <= TODAY (date literal, no bind variable needed).
    • 2Auto_Renew__c true -> Renewal_Date__c.addMonths(1), Status__c stays Active; otherwise Status__c = Expired.
    • 3Validate Amount__c > 0 first and throw a custom SubscriptionRenewalException for invalid records, caught per-record.
    • 4Database.update(toUpdate, false) returns a List — check isSuccess() per index to update the renewed/expired/failed counters.

    Practice All 58 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
    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