Apex Developer Best Practices

If you’re writing Apex code daily, following best practices isn’t optional. Whether you’re building logic-heavy processes or integrating with external systems, clean, efficient and scalable Apex keeps your Salesforce org healthy as it grows.

Let’s walk through the Apex best practices every Salesforce developer should follow, each with a short example of what to avoid and what to write instead.

No Hardcoded IDs

Avoid hardcoding record IDs. These values are unique to an org and won’t work across different environments. If you need reference values, look them up from the schema or store them in Custom Metadata or Custom Labels. Hardcoded IDs are one of the most common causes of deployment failures.

// Avoid: this Id exists in one org only
if (acc.RecordTypeId == '012000000000ABCAAA') {
    acc.Rating = 'Hot';
}

// Better: ask the schema, by developer name
Id partnerTypeId = Schema.SObjectType.Account
    .getRecordTypeInfosByDeveloperName()
    .get('Partner')
    .getRecordTypeId();

if (acc.RecordTypeId == partnerTypeId) {
    acc.Rating = 'Hot';
}

Code Bulkification

Your code should be designed to handle many records at once. A trigger does not fire once per record. It fires once per batch of up to 200 records, whether they come from a data load, an integration or a list view edit. Whether it’s a trigger, a helper class, or a batch job, always write for a list of records, never for one.

Avoid DML and SOQL Inside Loops

Placing SOQL queries or DML statements inside loops is the fastest way to hit governor limits. Collect the data in lists or maps, query once before the loop, and run your DML once after it.

// Avoid: one query and one update per account
for (Account acc : Trigger.new) {
    List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id];
    for (Contact con : contacts) {
        con.MailingCity = acc.BillingCity;
    }
    update contacts;
}

// Better: one query and one update for any number of accounts
List<Contact> contacts = [
    SELECT Id, AccountId
    FROM Contact
    WHERE AccountId IN :Trigger.newMap.keySet()
];

for (Contact con : contacts) {
    con.MailingCity = Trigger.newMap.get(con.AccountId).BillingCity;
}
update contacts;

Both snippets belong in an Account update trigger. With 200 accounts, the first version runs 200 queries and fails at the 101st. The second runs one.

Use Efficient Queries

Query only the fields you need and apply filters that narrow the results in a meaningful way. A well-filtered query is faster and uses less memory. An unfiltered query on a large object can return tens of thousands of rows and stop the transaction. For the detail, see my guide to SOQL and SOSL.

Use Collections (Lists, Sets, Maps)

Collections make your code cleaner and faster. A Map<Id, SObject> lets you find a related record instantly, which removes nested loops. This is especially useful when working with parent-child relationships or cross-object logic.

// A query result goes straight into a Map keyed by Id
Map<Id, Account> accountsById = new Map<Id, Account>(
    [SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds]
);

for (Contact con : contacts) {
    Account parent = accountsById.get(con.AccountId);
    if (parent != null && parent.Industry == 'Media') {
        con.Description = 'Media account contact';
    }
}

Use a Set<Id> to collect IDs without duplicates, a List when order matters, and a Map when you need to look something up.

One Trigger Per Object

Multiple triggers on a single object can lead to unpredictable results, because Salesforce doesn’t guarantee the order they run in. Stick to one trigger per object and route the logic by trigger event through a handler class.

Trigger Framework Usage

A good trigger framework helps you organize business logic cleanly. Keep the trigger itself lightweight and move the logic into a handler class, with one method per context such as onBeforeInsert or onAfterUpdate. This makes the code easier to read, test and change.

trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
    AccountTriggerHandler handler = new AccountTriggerHandler();

    if (Trigger.isBefore && Trigger.isInsert) {
        handler.onBeforeInsert(Trigger.new);
    } else if (Trigger.isBefore && Trigger.isUpdate) {
        handler.onBeforeUpdate(Trigger.new, Trigger.oldMap);
    } else if (Trigger.isAfter && Trigger.isInsert) {
        handler.onAfterInsert(Trigger.new);
    } else if (Trigger.isAfter && Trigger.isUpdate) {
        handler.onAfterUpdate(Trigger.new, Trigger.oldMap);
    }
}
public with sharing class AccountTriggerHandler {
    public void onBeforeInsert(List<Account> newAccounts) {
        for (Account acc : newAccounts) {
            if (acc.Industry == 'Media') {
                acc.Rating = 'Hot';
            }
        }
    }

    public void onBeforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {}
    public void onAfterInsert(List<Account> newAccounts) {}
    public void onAfterUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {}
}

The trigger has no business logic in it. Everything a future developer needs to read is in one class.

Stop Triggers from Running Twice

An after-update trigger that updates the same object fires itself again. So does a Flow or workflow field update on that object. Guard against it with a static set of the IDs you have already handled.

public class AccountTriggerGuard {
    private static Set<Id> processedIds = new Set<Id>();

    public static List<Account> notYetProcessed(List<Account> records) {
        List<Account> fresh = new List<Account>();
        for (Account acc : records) {
            if (!processedIds.contains(acc.Id)) {
                processedIds.add(acc.Id);
                fresh.add(acc);
            }
        }
        return fresh;
    }
}

Use a set of IDs, not a single static Boolean. An Apex update of 500 records runs the trigger three times in one transaction. A Boolean flag set during the first 200 would silently skip the other 300.

Enhanced For Loops

Use the for-each form when going through a collection. It reads better and there is no index to get wrong. Keep the traditional for (Integer i = 0; ...) loop for the cases where you need the position.

When a query can return thousands of records, loop over the query itself. Salesforce then gives you the records in batches of 200 instead of loading all of them into memory at once.

for (List<Contact> batch : [SELECT Id, Email FROM Contact WHERE Email != null]) {
    // 200 contacts at a time, so heap use stays low
}

Use Limits Methods

Salesforce provides methods like Limits.getQueries() and Limits.getDMLStatements() to check how close you are to a governor limit. Use them in complex logic, or log them while debugging, to catch a problem before it stops the transaction.

System.debug('SOQL used: ' + Limits.getQueries() + ' of ' + Limits.getLimitQueries());
System.debug('CPU used: ' + Limits.getCpuTime() + ' of ' + Limits.getLimitCpuTime() + ' ms');

These are the limits you will meet most often:

Limit per transactionSynchronousAsynchronous
SOQL queries100200
Records retrieved by SOQL50,00050,000
DML statements150150
Records processed by DML10,00010,000
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
Callouts100100

Future Methods and Asynchronous Apex

Move non-critical or time-consuming work to asynchronous processes like @future, Queueable, or Batch Apex. This takes load off the synchronous transaction and keeps the screen fast for the user. Don’t overuse it, because async has its own limits.

ToolUse it forGood to know
@futureA callout or a small job after the savePrimitive parameters only, no chaining, 50 calls per transaction
QueueableMost asynchronous workAccepts objects, returns a job ID, can chain to another job
Batch ApexThousands to millions of recordsWorks in chunks of 200 by default, each with fresh limits
Scheduled ApexWork that runs at a set timeUsually starts a batch or queueable job

For new code, pick Queueable over @future unless you have a reason not to. For big data sets, see managing large data volumes.

Write Secure Code

Apex runs in system mode by default, which means it can read and change data the user is not allowed to see. Make the secure choice the default.

  • Declare classes with sharing unless you have a documented reason not to.
  • Add WITH USER_MODE to queries and use insert as user or update as user for DML, so object permissions, field-level security and sharing are enforced.
  • Use bind variables in queries. Never build a query by joining user input into a string.
public with sharing class OpportunityService {
    public static List<Opportunity> openDeals(Id accountId) {
        return [
            SELECT Id, Name, Amount
            FROM Opportunity
            WHERE AccountId = :accountId AND IsClosed = false
            WITH USER_MODE
        ];
    }
}

Handle Errors on Purpose

An empty catch block hides a failure from everyone, including you. Catch the specific exception you expect, log it somewhere a person will look, and decide what the user should see.

// Partial success: save what is valid, report what is not
List<Database.SaveResult> results = Database.update(contacts, false);

for (Integer i = 0; i < results.size(); i++) {
    if (!results[i].isSuccess()) {
        System.debug(LoggingLevel.ERROR,
            contacts[i].Id + ': ' + results[i].getErrors()[0].getMessage());
    }
}

In a trigger, use record.addError('message') to stop the save and show the user a clear message instead of a stack trace.

Test Behaviour, Not Coverage

75% coverage is the minimum Salesforce needs to deploy. It is not proof that the code works. A useful test checks what the code did.

  • Create your own test data. Don’t rely on SeeAllData=true.
  • Test with 200 records, not one. That is how you find out the code is not bulkified.
  • Cover the positive case, the negative case and a user with restricted access (System.runAs).
  • Assert the outcome. A test with no assertions only proves the code didn’t crash.
  • Wrap the action in Test.startTest() and Test.stopTest() to get fresh limits and to run async code.
@isTest
private class AccountTriggerHandlerTest {
    @isTest
    static void setsRatingForMediaAccountsInBulk() {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            accounts.add(new Account(Name = 'Media ' + i, Industry = 'Media'));
        }

        Test.startTest();
        insert accounts;
        Test.stopTest();

        Integer hot = [SELECT COUNT() FROM Account WHERE Rating = 'Hot'];
        Assert.areEqual(200, hot, 'Every Media account should be rated Hot');
    }
}

Keep It Readable

  • Name things for what they hold: accountsById, not m1.
  • Keep methods short and doing one thing.
  • Put repeated text values in constants, Custom Labels or Custom Metadata.
  • Write comments that explain why, not what.
  • Remove debug statements and dead code before you deploy.

Checklist Before You Deploy

  • No SOQL or DML inside a loop
  • No hardcoded IDs, URLs or credentials
  • One trigger per object, logic in a handler
  • Works with 200 records in a test
  • Sharing and field-level security considered
  • Errors handled and visible, not swallowed
  • Tests assert results

FAQs

What does bulkification mean in Apex?

Writing code that handles a list of records in one pass, with queries and DML outside the loops, so it works the same for 1 record and for 200.

Why only one trigger per object?

Salesforce does not guarantee the order in which several triggers on the same object run. One trigger calling a handler gives you a fixed, readable order.

What is the difference between with sharing and without sharing?

with sharing respects the record access of the running user. without sharing ignores it. inherited sharing takes the mode of the class that called it. None of them enforce field-level security, which is what WITH USER_MODE is for.

Should logic go in a trigger or a Flow?

Simple field updates on the same record fit well in a before-save Flow. Complex logic, heavy data volumes and anything that needs careful limit handling belong in Apex. Whichever you choose, avoid splitting one piece of logic across both.

How do I avoid the Too many SOQL queries: 101 error?

Find the query that runs inside a loop, including inside a method called from a loop. Move it before the loop and use a Map to look up the results.

Wrapping Up

Being a great Apex developer means balancing power with responsibility. Write code that’s clean, scalable, and mindful of the platform’s limits. Stick to these best practices and you’ll avoid errors and build solutions that last.

Want to go further with this?

Learn it step by step in a course, or talk it through on a 1:1 call.

Shopping Cart