Part 42: Debugging Apex in Salesforce
Welcome back to the Salesforce series. You have been writing Apex for a few posts now. You know how to declare variables, write triggers, build classes, and work with SOQL. But here is the uncomfortable truth: you will spend more time debugging code than writing it. Every developer does. The difference between a junior developer who stares at a broken class for three hours and a senior developer who finds the bug in ten minutes is not intelligence. It is technique.
This is Part 42 of the series, and it is entirely dedicated to the art and science of debugging Apex. We will cover every tool Salesforce gives you, build practical habits, and finish with a hands-on project where you fix a class riddled with bugs. By the end, you will have a repeatable process for diagnosing and solving problems in Apex code.
What is Debugging?
Debugging is the process of finding and fixing errors in your code. The term dates back to the early days of computing, but the concept is simple: something is not working the way you expected, and you need to figure out why.
In Salesforce, debugging matters more than in many other environments because of the platform’s unique constraints. You cannot attach a traditional debugger and step through code line by line in real time the way you would in Java or C#. Instead, you rely on debug logs, replay debuggers, and strategic output statements. The platform also enforces governor limits, which means your code can work perfectly with one record and fail catastrophically with two hundred.
The Debugging Mindset
Before we talk about tools, let us talk about mindset. Good debugging starts with a systematic approach, not random changes.
- Reproduce the problem. If you cannot make the bug happen consistently, you cannot verify that you fixed it. Get the exact steps, the exact data, and the exact user profile that triggers the issue.
- Read the error message. This sounds obvious, but many developers skip it. Salesforce error messages often tell you the exact line number and the exact problem. Read every word.
- Form a hypothesis. Before you start changing code, have a theory about what is wrong. “I think the null pointer is happening because the SOQL query returns no results when the Account has no Contacts.”
- Test your hypothesis. Use the tools in this post to confirm or disprove your theory.
- Fix one thing at a time. If you change three things at once and the bug disappears, you do not know which change fixed it. You also do not know if you introduced new problems.
- Verify the fix. Run the scenario that caused the bug again. Then run related scenarios to make sure you did not break anything else.
Common Apex Bugs
Here are the bugs you will encounter most often in your Salesforce career:
Null Pointer Exceptions — The single most common Apex error. You try to call a method or access a property on a variable that is null. This happens when a SOQL query returns no results, when a map lookup returns null, or when you assume a related record exists.
// This will throw a NullPointerException if there is no matching Account
Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Acme' LIMIT 1];
Contact con = [SELECT Id FROM Contact WHERE AccountId = :acc.Id LIMIT 1];
String contactName = con.Name; // BOOM — con might be null
Governor Limit Violations — SOQL queries inside loops, DML statements inside loops, and excessive CPU time. These errors often only appear when processing records in bulk.
// This works with 1 record, fails with 101+
for (Contact con : Trigger.new) {
Account acc = [SELECT Id, Name FROM Account WHERE Id = :con.AccountId];
// Governor limit: too many SOQL queries
}
Logic Errors — The code runs without throwing an exception, but it produces the wrong result. A condition is backwards, an off-by-one error skips the last item in a list, or a formula calculates the wrong value. These are the hardest bugs to find because Salesforce does not tell you anything is wrong.
Bulk Data Issues — Code that works in the UI (single record) but breaks when data is loaded via Data Loader or when a trigger fires on multiple records. The classic example is initializing a variable inside a loop instead of outside it.
Type and Field Reference Errors — Using the wrong field API name, trying to assign a String to an Integer, or referencing a field that does not exist on the queried object. These are usually caught at compile time, but dynamic references and generic sObjects can slip through.
How to View Debug Logs in the Developer Console
The Developer Console is the built-in debugging tool that every Salesforce org includes. It is not pretty, it is not modern, but it is available everywhere and it is the tool you will use most often.
Opening the Developer Console
- Click the gear icon in the top-right corner of Salesforce.
- Select Developer Console.
- A new browser window opens with the Developer Console interface.
The console has a menu bar at the top, a tab-based workspace in the center, and a log panel at the bottom. When you first open it, it may look overwhelming. Do not worry. We will focus on the parts that matter.
Setting Up Trace Flags
Debug logs are not generated automatically for every transaction. You need to tell Salesforce to start capturing them by setting up a trace flag.
- In the Developer Console, go to Debug > Change Log Levels.
- Click Add in the “Monitored Users” section.
- Search for and select your user (or another user whose transactions you want to debug).
- Set the Start Date and Expiration Date. Trace flags expire after a maximum of 24 hours, and you can set them for shorter periods.
- Click the Add button to save.
You can also set trace flags in Setup:
- Navigate to Setup > Debug Logs.
- Click New in the “User Trace Flags” section.
- Select the traced entity type (User, Apex Class, or Apex Trigger).
- Select the specific user, class, or trigger.
- Set the start and expiration times.
- Choose a Debug Level (or create a new one).
- Click Save.
Understanding Log Levels
Each trace flag uses a debug level that controls how much information is captured in each log category. The levels, from least verbose to most verbose, are:
| Level | Description |
|---|---|
| NONE | Nothing is logged for this category. |
| ERROR | Only errors are logged. |
| WARN | Errors and warnings. |
| INFO | Errors, warnings, and informational messages. |
| DEBUG | All of the above plus debug-level messages. This is where your System.debug statements appear. |
| FINE | Detailed information including variable assignments. |
| FINER | Even more detail. |
| FINEST | Maximum verbosity. Everything is logged. |
A common beginner mistake is setting everything to FINEST. This generates enormous logs that are hard to read and can exceed the 20 MB log size limit, causing the log to be truncated. A truncated log is worse than a smaller, complete log because you might lose the exact line where the error occurs.
Recommended starting levels for general debugging:
| Category | Level |
|---|---|
| Database | INFO |
| Apex_Code | DEBUG |
| Apex_Profiling | INFO |
| Callout | INFO |
| System | DEBUG |
| Validation | INFO |
| Workflow | INFO |
| Visualforce | INFO |
Log Categories Explained
Each category controls a different aspect of the transaction:
- Database — SOQL queries, DML operations, and query results. Set to FINE or FINER if you need to see the actual rows returned by queries.
- Apex_Code — Your Apex code execution, including
System.debugstatements. This is the category you will adjust most often. - Apex_Profiling — Cumulative resource usage, method call counts, and execution times. Essential for performance debugging.
- Callout — HTTP callouts to external services. Set to FINE if you need to see request and response bodies.
- System — System methods like
System.now(),String.valueOf(), and other platform calls. - Validation — Validation rule evaluations. Useful when you need to know which rule fired.
- Workflow — Workflow rule evaluations and actions. Also covers Process Builder and some Flow executions.
- Visualforce — Visualforce page events, view state information, and component rendering. Only relevant if you work with Visualforce pages.
Reading the Raw Log
When you execute an action (save a record, run Anonymous Apex, etc.), a new log appears in the Logs tab at the bottom of the Developer Console. Double-click a log to open it.
The raw log is a wall of text. Each line starts with a timestamp and an event type. Here are the most important event types to look for:
11:32:15.045 (45678901)|EXECUTION_STARTED
11:32:15.045 (45678902)|CODE_UNIT_STARTED|[EXTERNAL]|01p....|MyTrigger on Account
11:32:15.050 (50123456)|SOQL_EXECUTE_BEGIN|[12]|SELECT Id, Name FROM Contact WHERE AccountId = :tmpVar
11:32:15.055 (55234567)|SOQL_EXECUTE_END|[12]|Rows:3
11:32:15.056 (56345678)|USER_DEBUG|[15]|DEBUG|Number of contacts found: 3
11:32:15.060 (60456789)|DML_BEGIN|[20]|Op:Update|Type:Contact|Rows:3
11:32:15.070 (70567890)|DML_END|[20]
11:32:15.071 (71678901)|CUMULATIVE_LIMIT_USAGE
11:32:15.071 (71678902)|LIMIT_USAGE_FOR_NS|(default)|
Number of SOQL queries: 1 out of 100
Number of DML statements: 1 out of 150
Maximum CPU time: 15 out of 10000
Maximum heap size: 1250 out of 6000000
11:32:15.072 (72789012)|CODE_UNIT_FINISHED|MyTrigger on Account
11:32:15.072 (72789013)|EXECUTION_FINISHED
Key things to note in the log above:
- SOQL_EXECUTE_BEGIN and SOQL_EXECUTE_END show you every query, the line number where it runs, and how many rows it returned.
- USER_DEBUG lines are your
System.debugoutput. The[15]is the line number in your code. - DML_BEGIN and DML_END show every insert, update, delete, or upsert operation.
- CUMULATIVE_LIMIT_USAGE appears at the end and tells you exactly how much of each governor limit you consumed.
Filtering Logs
The Developer Console provides a Filter option in the log viewer. You can type a string (like USER_DEBUG or FATAL_ERROR) in the filter bar to show only lines that contain that text. This is invaluable when working with large logs.
You can also check the Debug Only checkbox to show only your System.debug output lines, hiding all the system-generated noise.
How to View Debug Logs in Your IDE
The Developer Console works, but it has limitations. It is slow with large logs, the interface is dated, and you cannot set breakpoints. Modern IDEs offer a better experience.
VS Code with the Apex Replay Debugger
The Salesforce Extensions for VS Code include the Apex Replay Debugger, which lets you step through a debug log as if you were using a real debugger. It is not real-time (you are replaying a log, not executing live), but it is far more intuitive than reading raw log text.
Setting Up
- Install Visual Studio Code.
- Install the Salesforce Extension Pack from the VS Code Marketplace.
- Make sure your project is connected to your Salesforce org using
sfdx force:auth:web:loginor the command palette.
Setting Trace Flags from VS Code
- Open the Command Palette (
Cmd+Shift+Pon Mac,Ctrl+Shift+Pon Windows). - Type and select SFDX: Turn On Apex Debug Log for Replay Debugger.
- This creates a trace flag for your user with appropriate log levels. The flag lasts 30 minutes by default.
Retrieving Logs
- Perform the action you want to debug in your Salesforce org (save a record, run a process, etc.).
- In VS Code, open the Command Palette and select SFDX: Get Apex Debug Logs.
- A list of recent logs appears. Select the one you want.
- The log file downloads into your project’s
.sfdx/tools/debug/logs/directory and opens in the editor.
Setting Checkpoints (Breakpoints)
The Replay Debugger uses checkpoints instead of traditional breakpoints. Checkpoints capture the state of all variables at a specific line of code.
- Open the Apex class or trigger you want to debug.
- Click in the gutter (left margin) next to the line where you want a checkpoint. A red dot appears.
- Open the Command Palette and select SFDX: Update Checkpoints in Org.
- You can set up to five checkpoints at a time.
- Now perform the action in Salesforce that runs the code.
- Retrieve the log and replay it.
Replaying a Log
- Open the downloaded log file in VS Code.
- Open the Command Palette and select SFDX: Launch Apex Replay Debugger with Current File.
- The debugger starts. You see familiar debugger controls: Step Over, Step Into, Step Out, Continue.
- The Variables panel on the left shows the values of all variables at the current execution point.
- The Call Stack panel shows the current method chain.
- Step through the code and watch variables change. When you reach a checkpoint, the full heap snapshot is available.
This is dramatically easier than reading a raw log. You can see exactly when a variable becomes null, exactly when a list goes empty, and exactly which branch of an if-statement the code enters.
IntelliJ with Illuminated Cloud
If you prefer IntelliJ IDEA, the Illuminated Cloud plugin provides similar capabilities. It supports Apex code navigation, SOQL editing, and log analysis. The debugging workflow is similar: retrieve a log and step through it. However, Illuminated Cloud is a paid plugin, and the Salesforce community has largely standardized on VS Code, so you will find more documentation and community support for the VS Code workflow.
How to Easily Traverse Through Code You Do Not Know
One of the most daunting experiences in Salesforce development is inheriting someone else’s code. You open a trigger and it calls a handler class, which calls a utility class, which calls another utility class. You have no idea what anything does or where a value comes from. Here is how to navigate unfamiliar code efficiently.
Reading Stack Traces
When an error occurs, Salesforce provides a stack trace. Learn to read it bottom to top:
System.NullPointerException: Attempt to de-reference a null object
Class.AccountUtility.getRelatedContacts: line 45, column 1
Class.AccountTriggerHandler.afterUpdate: line 22, column 1
Trigger.AccountTrigger: line 5, column 1
This tells you:
- The trigger on line 5 called
AccountTriggerHandler.afterUpdateon line 22. - That method called
AccountUtility.getRelatedContactson line 45. - On line 45 of
AccountUtility, something was null.
Start at the top (the most specific location) and work your way down to understand the call chain.
Using Go to Definition
In VS Code with the Salesforce extensions, you can Cmd+Click (Mac) or Ctrl+Click (Windows) on any class name, method name, or variable to jump to its definition. This is called “Go to Definition” and it is your best friend when navigating unfamiliar code.
In the Developer Console, you can use File > Open and type the name of a class to open it. There is no direct “Go to Definition” feature, which is one of many reasons VS Code is the preferred tool.
Following the Call Chain
When you need to understand a complex process:
- Start at the trigger. Read what objects it fires on and what events it handles (before insert, after update, etc.).
- Follow each method call into the handler class.
- For each method in the handler, note what it does: queries data, processes data, performs DML.
- Map out the flow on paper or in a diagram tool if it is complex.
Do not try to understand everything at once. Focus on the path that is relevant to your bug.
Using Execute Anonymous for Quick Tests
The Developer Console’s Debug > Open Execute Anonymous Window (or Ctrl+E) is one of the most useful debugging tools available. You can write and execute snippets of Apex code without creating a class or trigger.
Use it to:
- Test a SOQL query to see what it returns.
- Call a specific method with known inputs.
- Check the value of a custom setting or custom metadata record.
- Verify that a field API name is correct.
// Quick test in Execute Anonymous
List<Account> accs = [SELECT Id, Name, Industry FROM Account WHERE Industry = 'Technology' LIMIT 5];
System.debug('Found ' + accs.size() + ' accounts');
for (Account a : accs) {
System.debug(a.Name + ' — ' + a.Industry);
}
This is faster than saving a class, running a test, and checking the log. Use Execute Anonymous constantly.
How and When to Use System.debug
System.debug is the most basic and most used debugging tool in Apex. It writes a message to the debug log that you can read in the Developer Console, VS Code, or any log viewer.
Basic Syntax
System.debug('Hello, this is a debug message');
This writes to the log at the default DEBUG level.
Using Log Levels
You can specify a log level to control when your message appears:
System.debug(LoggingLevel.ERROR, 'Something went very wrong');
System.debug(LoggingLevel.WARN, 'This might be a problem');
System.debug(LoggingLevel.INFO, 'Processing account: ' + acc.Name);
System.debug(LoggingLevel.DEBUG, 'Variable x = ' + x);
System.debug(LoggingLevel.FINE, 'Entered method calculateDiscount');
If the trace flag’s Apex_Code level is set to INFO, then only ERROR, WARN, and INFO messages will appear. DEBUG and FINE messages will be hidden. This lets you leave detailed debugging statements in your code and only see them when you explicitly raise the log level.
Formatting Output for Readability
When debugging, you often need to inspect complex data structures. Raw output can be hard to read. Here are some techniques:
Debugging Collections:
List<String> names = new List<String>{'Alice', 'Bob', 'Charlie'};
System.debug('Names list: ' + names);
// Output: Names list: (Alice, Bob, Charlie)
Map<Id, Account> accountMap = new Map<Id, Account>([SELECT Id, Name FROM Account LIMIT 3]);
System.debug('Account map: ' + accountMap);
// Output is messy for large maps
Using JSON.serializePretty for Complex Objects:
Account acc = [SELECT Id, Name, Industry, BillingCity,
(SELECT Id, FirstName, LastName FROM Contacts)
FROM Account WHERE Name = 'Acme' LIMIT 1];
System.debug('Account details:\n' + JSON.serializePretty(acc));
This produces output like:
{
"attributes" : {
"type" : "Account",
"url" : "/services/data/v60.0/sobjects/Account/001..."
},
"Id" : "001...",
"Name" : "Acme",
"Industry" : "Technology",
"BillingCity" : "San Francisco",
"Contacts" : {
"records" : [ {
"Id" : "003...",
"FirstName" : "John",
"LastName" : "Smith"
} ]
}
}
This is dramatically easier to read than the default sObject toString() output.
Labeling Your Debug Statements:
Always include context in your debug messages. Do not write System.debug(x). Write System.debug('Discount percentage after tier calculation: ' + x). When you have dozens of debug lines in a log, you need to know which one you are looking at.
Adding Separators for Sections:
System.debug('===== STARTING DISCOUNT CALCULATION =====');
// ... calculation code ...
System.debug('===== DISCOUNT CALCULATION COMPLETE — Result: ' + discount + ' =====');
Removing Debug Statements Before Deployment
Debug statements that are useful during development become noise in production logs. Follow these guidelines:
- Remove or comment out
System.debugstatements before deploying to production, especially those inside loops. - If you want to keep some debug statements for ongoing troubleshooting, use
LoggingLevel.FINEorLoggingLevel.FINERso they only appear when someone deliberately raises the log level. - Some teams use a custom logging utility class that can be toggled on or off with a custom setting, avoiding the need to add and remove debug statements entirely.
How and When to Use Code Chunking
Code chunking is not an official Salesforce term. It is a practical debugging technique where you isolate sections of code to narrow down the source of a problem. Think of it as a binary search for bugs.
The Concept
When you have a method with fifty lines of code and something is wrong, you do not need to understand all fifty lines at once. Instead:
- Comment out the bottom half of the method. Run the code. Does the error still occur?
- If yes, the bug is in the top half. Comment out the bottom half of the top half. Repeat.
- If no, the bug is in the bottom half. Uncomment the bottom half, comment out the top half. Repeat.
In three or four iterations, you have narrowed a fifty-line bug down to a five-line section.
Binary Search Debugging in Practice
public static void processAccounts(List<Account> accounts) {
// Step 1: Query related contacts
Map<Id, List<Contact>> contactsByAccount = getContactMap(accounts);
// Step 2: Calculate account scores
Map<Id, Decimal> scores = calculateScores(accounts, contactsByAccount);
// Step 3: Update account ratings
updateRatings(accounts, scores);
// Step 4: Send notifications
sendNotifications(accounts, scores);
// Step 5: Update audit log
updateAuditLog(accounts);
}
If this method is throwing an error, comment out steps 3 through 5. If the error disappears, uncomment step 3 alone. If it comes back, the bug is in updateRatings. Now go into that method and repeat the process.
Testing Methods in Isolation with Execute Anonymous
Once you have identified the suspect method, test it in isolation:
// In Execute Anonymous
List<Account> testAccounts = [SELECT Id, Name, Rating FROM Account LIMIT 5];
Map<Id, Decimal> testScores = new Map<Id, Decimal>();
for (Account a : testAccounts) {
testScores.put(a.Id, 85.5);
}
MyClass.updateRatings(testAccounts, testScores);
System.debug('Completed without error');
This lets you test a single method with controlled inputs, eliminating all the other variables. If it works in isolation but fails in the full process, the problem is likely with the data being passed to it from an earlier step.
When to Use Code Chunking
- When the error message is vague or misleading.
- When the method is long and complex.
- When you are dealing with someone else’s code and do not understand the full logic.
- When adding
System.debugto every line would be tedious.
Code chunking is fast, low-tech, and effective. It does not require any special tools — just the ability to comment out code and run it.
How to Use the Developer Console Log Panel to Identify Code Bottlenecks
Sometimes your code works correctly but is too slow, uses too much heap, or comes dangerously close to governor limits. The Developer Console has built-in analysis tools for these performance problems.
The Execution Overview Panel
After opening a debug log in the Developer Console, look at the bottom section of the log viewer. You will see several tabs: Stack Tree, Execution Stack, Execution Overview, Performance Tree, and others (depending on your perspective).
The Execution Overview panel gives you a summary of the transaction:
- Total execution time.
- Number of SOQL queries and rows returned.
- Number of DML statements and rows affected.
- Heap size at peak.
- CPU time consumed.
This is your starting point. If the overview shows 95 SOQL queries out of a 100 limit, you know exactly where to focus.
Switching to the Analysis Perspective
By default, the Developer Console opens in the “Standard” perspective. For performance analysis, switch to the analysis perspective:
- Go to Debug > Switch Perspective > Analysis (or Debug > Perspective Manager to create a custom perspective).
- The Analysis perspective rearranges the panels to focus on the Execution Tree and Timeline.
The Timeline Tab
The Timeline tab (located below the log source when in the appropriate perspective) shows a visual representation of the transaction over time. It displays colored bars for different event categories:
- Blue bars represent Apex code execution.
- Brown/tan bars represent database operations (SOQL and DML).
- Red bars represent workflow and flow execution.
- Green bars represent callout time.
A long brown bar tells you a query is slow. Multiple thin brown bars in a row suggest queries inside a loop. A wide blue bar indicates CPU-heavy Apex processing.
The Execution Tree
The Execution Tree panel shows the transaction as a hierarchical tree of operations. You can expand nodes to drill into specific methods and see:
- How many times each method was called.
- How long each call took.
- What SOQL queries and DML operations occurred inside each method.
This is particularly useful for finding methods that are called more times than you expected (often due to loops or recursive trigger execution).
The Performance Tree
The Performance Tree aggregates execution data by method. Instead of showing every individual call, it shows:
- Total time spent in each method across all calls.
- Number of invocations for each method.
- Self time versus total time (self time excludes time spent in methods called by this method).
Sort by total time to find your biggest bottlenecks. A method that is called once but takes 5,000 milliseconds is a problem. A method that takes 2 milliseconds but is called 500 times is also a problem.
Identifying Common Bottlenecks
Slow Queries: Look for SOQL_EXECUTE_BEGIN events that have a large gap before SOQL_EXECUTE_END. This means the query itself is slow. Solutions include adding indexes (by contacting Salesforce Support for custom indexes or using skinny tables), simplifying WHERE clauses, or reducing the number of fields queried.
Heap Usage: Search the log for HEAP_ALLOCATE events. If you see large allocations, you might be loading too many records into memory at once. Use FOR loops with SOQL (SOQL for-loops) to process records in batches:
// Bad: loads all records into memory at once
List<Account> allAccounts = [SELECT Id, Name FROM Account];
// Better: processes 200 at a time
for (List<Account> batch : [SELECT Id, Name FROM Account]) {
// process batch
}
Governor Limit Consumption: The CUMULATIVE_LIMIT_USAGE section at the end of every log tells you exactly where you stand. Pay special attention to:
- Number of SOQL queries: 100 synchronous, 200 asynchronous. If you are using more than 50 in a synchronous context, refactor.
- Number of DML statements: 150 limit. Consolidate DML operations by collecting records into lists and performing a single insert, update, or delete.
- CPU time: 10,000 ms synchronous, 60,000 ms asynchronous. Optimize loops, avoid redundant processing, and consider moving heavy work to asynchronous execution.
- Heap size: 6 MB synchronous, 12 MB asynchronous. Avoid storing large data sets in memory unnecessarily.
CUMULATIVE_LIMIT_USAGE
LIMIT_USAGE_FOR_NS|(default)|
Number of SOQL queries: 47 out of 100 ← Getting close
Number of query rows: 8450 out of 50000
Number of SOQL queries: 47 out of 100
Number of DML statements: 12 out of 150 ← Healthy
Number of DML rows: 340 out of 10000
Maximum CPU time: 4521 out of 10000 ← Watch this
Maximum heap size: 2345678 out of 6000000
Number of callouts: 0 out of 100
Number of future calls: 0 out of 50
PROJECT: Your First Debugging Nightmare
It is time to put everything together. Below is a broken Apex class with a trigger that is supposed to calculate and update a “Priority Score” on Accounts based on their related Contacts and Opportunities. The class has five bugs. Your job is to find and fix every one of them using the techniques from this post.
The Broken Code
Here is the trigger:
trigger AccountPriorityTrigger on Account (after update) {
AccountPriorityCalculator.calculatePriority(Trigger.new);
}
Here is the class:
public class AccountPriorityCalculator {
public static void calculatePriority(List<Account> accounts) {
List<Account> accountsToUpdate = new List<Account>();
for (Account acc : accounts) {
// Bug 1 is hiding here
Contact primaryContact = [SELECT Id, FirstName, LastName, Email
FROM Contact
WHERE AccountId = :acc.Id
AND Is_Primary__c = true
LIMIT 1];
// Bug 2 is hiding here
String contactName = primaryContact.FirstName + ' ' + primaryContact.LastName;
System.debug('Primary contact: ' + contactName);
// Bug 3 is hiding here
List<Opportunity> opps = [SELECT Id, Amount, StageName
FROM Opportunity
WHERE AccountId = :acc.Id
AND StageName != 'Closed Lost'];
Decimal totalAmount = 0;
for (Integer i = 0; i <= opps.size(); i++) {
// Bug 4 is hiding here
totalAmount += opps[i].Amount;
}
Decimal priorityScore = 0;
if (totalAmount > 100000) {
priorityScore = 100;
} else if (totalAmount > 50000) {
priorityScore = 75;
} else if (totalAmount > 10000) {
priorityScore = 50;
} else {
priorityScore = 25;
}
// Bug 5 is hiding here
acc.Priority_Score__c = priorityScore;
accountsToUpdate.add(acc);
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
}
Finding and Fixing the Bugs
Take a few minutes to look at the code above and try to identify the bugs yourself before reading the solutions below. Use the debugging mindset: read the code line by line, think about what could go wrong, and form hypotheses.
Bug 1: SOQL Query Inside a Loop
// THE BUG
for (Account acc : accounts) {
Contact primaryContact = [SELECT Id, FirstName, LastName, Email
FROM Contact
WHERE AccountId = :acc.Id
AND Is_Primary__c = true
LIMIT 1];
How you would find it: Open the debug log and look at the CUMULATIVE_LIMIT_USAGE section. If you updated 5 accounts, you would see “Number of SOQL queries: 10 out of 100” (two queries per account — one for Contacts, one for Opportunities). With 50+ accounts, you would hit the limit.
You could also search the log for SOQL_EXECUTE_BEGIN and count how many times it appears. If it matches the number of records multiplied by the number of queries in the loop, you have confirmed the problem.
The Fix: Move the query outside the loop and use a Map.
// Build a map of primary contacts before the loop
Map<Id, Contact> primaryContactMap = new Map<Id, Contact>();
for (Contact c : [SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE AccountId IN :accountIds
AND Is_Primary__c = true]) {
primaryContactMap.put(c.AccountId, c);
}
Bug 2: Null Pointer Exception — No Null Check on Query Result
// THE BUG
Contact primaryContact = [SELECT ...]; // Could return null
String contactName = primaryContact.FirstName + ' ' + primaryContact.LastName; // BOOM
How you would find it: Run the code with an Account that has no primary Contact. The debug log will show:
FATAL_ERROR System.NullPointerException: Attempt to de-reference a null object
Class.AccountPriorityCalculator.calculatePriority: line 14, column 1
The line number points you directly to the primaryContact.FirstName line.
The Fix: Add a null check.
Contact primaryContact = primaryContactMap.get(acc.Id);
String contactName = 'No Primary Contact';
if (primaryContact != null) {
contactName = primaryContact.FirstName + ' ' + primaryContact.LastName;
}
System.debug('Primary contact: ' + contactName);
Bug 3: Second SOQL Query Inside the Loop
// THE BUG
List<Opportunity> opps = [SELECT Id, Amount, StageName
FROM Opportunity
WHERE AccountId = :acc.Id
AND StageName != 'Closed Lost'];
How you would find it: The same CUMULATIVE_LIMIT_USAGE analysis as Bug 1. This is the second query inside the loop.
The Fix: Same pattern — query outside the loop, use a Map.
Map<Id, List<Opportunity>> oppsByAccount = new Map<Id, List<Opportunity>>();
for (Opportunity opp : [SELECT Id, Amount, StageName, AccountId
FROM Opportunity
WHERE AccountId IN :accountIds
AND StageName != 'Closed Lost']) {
if (!oppsByAccount.containsKey(opp.AccountId)) {
oppsByAccount.put(opp.AccountId, new List<Opportunity>());
}
oppsByAccount.get(opp.AccountId).add(opp);
}
Bug 4: Off-By-One Error in the Loop
// THE BUG
for (Integer i = 0; i <= opps.size(); i++) {
totalAmount += opps[i].Amount;
}
How you would find it: This throws a System.ListException: List index out of bounds: 3 (where 3 is the size of the list). The debug log shows the exact line. You could also add a System.debug statement before the line:
System.debug('List size: ' + opps.size() + ', current index: ' + i);
When you see current index: 3 and List size: 3, you realize the loop goes one step too far.
The Fix: Change <= to <.
for (Integer i = 0; i < opps.size(); i++) {
totalAmount += opps[i].Amount;
}
// Or even better, use a for-each loop:
for (Opportunity opp : opps) {
totalAmount += opp.Amount != null ? opp.Amount : 0;
}
Notice that the improved version also adds a null check on Amount. Opportunity Amount can be null, which would cause another error.
Bug 5: DML on Trigger.new Records in an After Trigger
// THE BUG — in an AFTER trigger, you cannot modify Trigger.new records
acc.Priority_Score__c = priorityScore;
accountsToUpdate.add(acc);
// ...
update accountsToUpdate; // System.FinalException: Record is read-only
How you would find it: The error message is clear: System.FinalException: Record is read-only. In an after trigger, the records in Trigger.new are read-only. You need to create new sObject instances with just the Id and the fields you want to update.
The Fix: Create new Account instances for the DML.
Account accToUpdate = new Account(
Id = acc.Id,
Priority_Score__c = priorityScore
);
accountsToUpdate.add(accToUpdate);
The Fixed Code
Here is the complete, corrected class:
public class AccountPriorityCalculator {
public static void calculatePriority(List<Account> accounts) {
Set<Id> accountIds = new Set<Id>();
for (Account acc : accounts) {
accountIds.add(acc.Id);
}
// Query contacts outside the loop
Map<Id, Contact> primaryContactMap = new Map<Id, Contact>();
for (Contact c : [SELECT Id, FirstName, LastName, Email, AccountId
FROM Contact
WHERE AccountId IN :accountIds
AND Is_Primary__c = true]) {
primaryContactMap.put(c.AccountId, c);
}
// Query opportunities outside the loop
Map<Id, List<Opportunity>> oppsByAccount = new Map<Id, List<Opportunity>>();
for (Opportunity opp : [SELECT Id, Amount, StageName, AccountId
FROM Opportunity
WHERE AccountId IN :accountIds
AND StageName != 'Closed Lost']) {
if (!oppsByAccount.containsKey(opp.AccountId)) {
oppsByAccount.put(opp.AccountId, new List<Opportunity>());
}
oppsByAccount.get(opp.AccountId).add(opp);
}
List<Account> accountsToUpdate = new List<Account>();
for (Account acc : accounts) {
// Null-safe contact lookup
Contact primaryContact = primaryContactMap.get(acc.Id);
String contactName = 'No Primary Contact';
if (primaryContact != null) {
contactName = primaryContact.FirstName + ' ' + primaryContact.LastName;
}
System.debug('Primary contact for ' + acc.Id + ': ' + contactName);
// Null-safe opportunity processing
List<Opportunity> opps = oppsByAccount.get(acc.Id);
Decimal totalAmount = 0;
if (opps != null) {
for (Opportunity opp : opps) {
totalAmount += opp.Amount != null ? opp.Amount : 0;
}
}
Decimal priorityScore = 0;
if (totalAmount > 100000) {
priorityScore = 100;
} else if (totalAmount > 50000) {
priorityScore = 75;
} else if (totalAmount > 10000) {
priorityScore = 50;
} else {
priorityScore = 25;
}
// Create new sObject instance for DML in after trigger
Account accToUpdate = new Account(
Id = acc.Id,
Priority_Score__c = priorityScore
);
accountsToUpdate.add(accToUpdate);
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
}
What You Practiced
In this project, you used:
- Debug log analysis to identify governor limit issues (Bugs 1 and 3).
- Stack trace reading to locate null pointer exceptions (Bug 2).
- System.debug to inspect loop variables and find the off-by-one error (Bug 4).
- Understanding of trigger context to recognize the read-only record issue (Bug 5).
- Code chunking — by analyzing the code section by section, you isolated each bug independently.
These are the same five techniques you will use on every debugging task for the rest of your Salesforce career. The bugs change, the objects change, the business logic changes, but the process stays the same.
Summary
Debugging is not a talent. It is a skill that you develop through practice and process. Here is what we covered:
- The debugging mindset: Reproduce, hypothesize, test, fix one thing, verify.
- Debug logs in the Developer Console: Trace flags, log levels, log categories, reading the raw log, filtering.
- Debug logs in VS Code: The Apex Replay Debugger, checkpoints, stepping through code with variable inspection.
- Navigating unfamiliar code: Stack traces, Go to Definition, following the call chain, Execute Anonymous for quick tests.
- System.debug: Log levels, formatting output, JSON.serializePretty, labeling statements, cleaning up before deployment.
- Code chunking: Binary search debugging by commenting out sections and isolating methods.
- The Log Panel: Execution Overview, Timeline, Execution Tree, Performance Tree, identifying slow queries and limit consumption.
- Hands-on debugging: Finding and fixing five common bugs in a real Apex class.
The next time you face a broken class or a mysterious error, you will have a complete toolkit to work with. Do not panic. Follow the process. Read the error message. Check the log. Narrow down the problem. Fix it. Verify.
Next up — Part 43: The Basics of Programmatic Security in Apex. We will cover CRUD checks, Field-Level Security enforcement, the WITH SECURITY_ENFORCED keyword, Security.stripInaccessible, sharing rules in Apex (with sharing, without sharing, inherited sharing), and why security is not optional when writing code that runs in a multi-tenant environment. See you there.