Common Apex Mistakes to Avoid in Salesforce

Writing efficient, scalable, and maintainable Apex code is crucial for Salesforce developers. Whether you’re working on a complex integration, automation, or custom business logic, avoiding bad practices can save you from major headaches down the line.

In this blog post, we’ll explore some of the most common Apex mistakes, with real-time examples, explanations, and best practices to help you write clean, reliable Apex code.

1. Using SOQL or DML Statements Inside Loops


Why It’s a Problem:

Putting SOQL or DML operations inside a loop might work fine in dev orgs. But when the record count goes up? Boom governor limits hit hard.

for(Account acc : accountList) {
    Contact con = [SELECT Id FROM Contact WHERE AccountId = :acc.Id LIMIT 1];
    con.Email = 'te**@*****le.com';
    update con;
}

Best Practice:

Query records outside the loop using a Map and batch DML operations.

Set<Id> accIds = new Set<Id>();
for(Account acc : accountList) {
    accIds.add(acc.Id);
}

Map<Id, Contact> conMap = new Map<Id, Contact>(
    [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accIds]
);

List<Contact> updatedContacts = new List<Contact>();
for(Account acc : accountList) {
    Contact con = conMap.get(acc.Id);
    if(con != null) {
        con.Email = 'te**@*****le.com';
        updatedContacts.add(con);
    }
}
update updatedContacts;

2. Ignoring Bulkification in Triggers


Why It’s a Problem:

Writing triggers that assume only one record is being processed? Not a good idea. Salesforce always processes in bulk even if you’re only testing with a single record.

trigger UpdateAccount on Contact (after insert) {
    Account acc = [SELECT Id, NumberOfEmployees FROM Account WHERE Id = :Trigger.new[0].AccountId];
    acc.NumberOfEmployees += 1;
    update acc;
}

This code crashes if more than one contact is inserted.

Best Practice:

Set<Id> accIds = new Set<Id>();
for (Contact con : Trigger.new) {
    accIds.add(con.AccountId);
}

Map<Id, Account> accMap = new Map<Id, Account>(
    [SELECT Id, NumberOfEmployees FROM Account WHERE Id IN :accIds]
);

for (Contact con : Trigger.new) {
    if (accMap.containsKey(con.AccountId)) {
        accMap.get(con.AccountId).NumberOfEmployees += 1;
    }
}

update accMap.values();

3. Hardcoding IDs or Picklist Values

You might be tempted to do this to get something working fast. But it’s one of the worst things you can do for maintainability.

Why It’s a Problem:

You’re writing logic that depends on record type IDs, user IDs, profile IDs, queue IDs, or specific picklist values and instead of dynamically referencing them, you’re hardcoding their values directly into the code.

This might work in your sandbox, but the moment you deploy to another environment , staging, UAT, or production , the IDs change. Suddenly, your flows fail, records don’t get routed, and users start seeing unexpected behavior.

if (record.RecordTypeId == '0125g000000Tz6a') {
    // do something
}

Best Practice:

Query them dynamically:

//Use Schema or queries to fetch metadata dynamically.

Id sysAdminId = [SELECT Id FROM Profile WHERE Name = 'System Administrator' LIMIT 1].Id;
if(UserInfo.getProfileId() == sysAdminId) {
    // Safe comparison
}

//picklists
List<Schema.PicklistEntry> stageValues = Schema.SObjectType.Opportunity.fields.StageName.getDescribe().getPicklistValues();

4. Ignoring Governor Limits

Apex runs in a multi-tenant environment. That means every piece of code you write shares resources with other customers. To prevent one org’s bad code from hogging everything, Salesforce enforces strict governor limits and if you hit one, the whole transaction fails.

Salesforce enforces strict limits on the number of SOQL, DML, CPU time, etc. Ignoring them leads to runtime exceptions.

Best Practice:

Monitor resource usage during development.

System.debug('SOQL Queries: ' + Limits.getQueries());
System.debug('DML Statements: ' + Limits.getDMLStatements());

Avoid designs that inherently consume more resources, and always test your code with large datasets.

5. Not Handling Exceptions Properly

Let’s be honest, many devs just wrap code in a try-catch and log the error. But that’s not enough. Exception handling isn’t just about catching errors. It’s about catching them well, knowing when to bubble them up, and making sure they don’t silently fail.

try {
    update accountList;
} catch (Exception e) {
    System.debug('Error: ' + e.getMessage());
}

Why this is a problem:

  • This swallows the error without taking action.
  • No feedback to the user.
  • No logging to a persistent store.
  • Debug logs vanish in production, so you’ll never know this happened.

Best Practice:

try {
    update accountList;
} catch (DmlException e) {
    // Custom error logging class
    ErrorLogger.logError('Account Update', e);

    // Optional: throw a user-friendly exception
    throw new CustomException('We couldn’t update the accounts. Please contact support.');
}

Logging Exception Details

Don’t just debug. Store them in a custom object or use a logging utility.

public class ErrorLogger {
    public static void logError(String context, Exception e) {
        System.debug('Error in ' + context + ': ' + e.getMessage());
        // Optional: Insert into a custom Logging__c object
        Logging__c log = new Logging__c(
            Context__c = context,
            Message__c = e.getMessage(),
            StackTrace__c = e.getStackTraceString()
        );
        insert log;
    }
}

6. Writing Triggers Without Considering Recursion

Your trigger updates a record, which calls the same trigger again, creating an infinite loop.

Use static variables to block recursion.

public class RecursionGuard {
    public static Boolean hasRun = false;
}
trigger AccountTrigger on Account (before update) {
    if (!RecursionGuard.hasRun) {
        RecursionGuard.hasRun = true;
        // logic here
    }
}

7. Forgetting to Use Test.startTest() and Test.stopTest()

Whats the Problem?

Most developers write test methods that create records, run the logic, and then assert something. But if you don’t use Test.startTest() and Test.stopTest(), you’re missing a critical part of how the Apex test engine works especially when you’re testing:

  • Asynchronous operations (@future, Queueable, Batch, Schedulable)
  • Governor limits (reset between startTest and stopTest)
  • Callout test flows and chaining logic

The common mistake is writing a test like this:

@isTest
static void testQueueableJobWrong() {
    // Create test data
    Account acc = new Account(Name = 'Test Account');
    insert acc;

    // Enqueue the job
    System.enqueueJob(new MyQueueableJob(acc.Id));

    // Assert something (but the job hasn't run yet!)
    System.assertEquals(...); // This will fail or not do what you think
}

Without Test.startTest() and Test.stopTest(), the queueable job or future method never executes during the test context. So your logic doesn’t actually get covered, and your assertions don’t reflect real behavior.

How Test.startTest() and Test.stopTest() Work

1. Reset Limits
The platform gives you governor limits like:

  • 100 SOQL queries
  • 150 DML statements
  • 10 callouts

When you run a test, these limits accumulate. Calling Test.startTest() resets all limits, so you get a clean slate to measure the real cost of the code under test.

2. Executes Async Code
The moment you call Test.stopTest(), Salesforce executes all enqueued asynchronous code synchronously for the sake of test coverage.

The Correct Way to Test Async Logic

@isTest
static void testQueueableJobCorrect() {
    Account acc = new Account(Name = 'Test Account');
    insert acc;

    Test.startTest();
    System.enqueueJob(new MyQueueableJob(acc.Id));
    Test.stopTest(); // Job is executed here

    // Now assert after the job has run
    Account updatedAcc = [SELECT Status__c FROM Account WHERE Id = :acc.Id];
    System.assertEquals('Processed', updatedAcc.Status__c);
}

Use Test.startTest() and stopTest() once per test method, and place them right around the action you want to measure or trigger, not at the top or bottom of the test method.

Final Thoughts

Here’s the thing, writing Apex isn’t just about getting your logic to work. It’s about making it work well within Salesforce’s rules, limits, and architecture. The platform is powerful, but it’s also strict. One sloppy trigger or lazy DML inside a loop can bring down an entire deployment.

The mistakes we covered – like hardcoding IDs, skipping bulkification, ignoring governor limits, or half-hearted exception handling are surprisingly common. But they’re also completely avoidable once you know what to look out for.

So take this as your Apex health checklist:

  • Think in bulk, not one record at a time
  • Always assume your code will scale because someday, it will
  • Never treat governor limits as optional
  • And test like your future self is the one who’ll debug it at 2 a.m.

Clean Apex doesn’t just work it earns trust, scales without drama, and makes your org safer for everyone using it.

Author

  • Satyam parasa

    Satyam Parasa is a Salesforce and Mobile application developer. Passionate about learning new technologies, he is the founder of Flutterant.com, where he shares his knowledge and insights.

    View all posts

Leave a Comment