Apex Best Practices Reference
1. Bulkification
The Problem
Apex triggers can process up to 200 records at once. Code that works for single records often fails at scale.
Anti-Pattern
// BAD: SOQL in loop - will hit 100 query limit
for (Account acc : Trigger.new) {
Contact c = [SELECT Id FROM Contact WHERE AccountId = :acc.Id LIMIT 1];
}
Best Practice
// GOOD: Query once, use Map for lookup
Set<Id> accountIds = new Map<Id, Account>(Trigger.new).keySet();
Map<Id, Contact> contactsByAccountId = new Map<Id, Contact>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
contactsByAccountId.put(c.AccountId, c);
}
for (Account acc : Trigger.new) {
Contact c = contactsByAccountId.get(acc.Id);
}
DML Bulkification
// BAD: DML in loop
for (Account acc : accounts) {
acc.Status__c = 'Active';
update acc;
}
// GOOD: Collect and update once
List<Account> toUpdate = new List<Account>();
for (Account acc : accounts) {
acc.Status__c = 'Active';
toUpdate.add(acc);
}
update toUpdate;
Test with Bulk Data
@isTest
static void testBulkOperation() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 251; i++) { // 251 to span two trigger batches
accounts.add(new Account(Name = 'Test ' + i));
}
Test.startTest();
insert accounts;
Test.stopTest();
Assert.areEqual(251, [SELECT COUNT() FROM Account]);
}
2. Collections Best Practices
Use Map Constructor for ID Extraction
// BAD: Loop to get IDs
Set<Id> accountIds = new Set<Id>();
for (Account acc : accounts) {
accountIds.add(acc.Id);
}
// GOOD: Map constructor
Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
Use Maps for Lookups
// Build lookup map
Map<Id, Account> accountsById = new Map<Id, Account>(accounts);
// O(1) lookup instead of O(n) loop
Account acc = accountsById.get(someId);
Collection Naming Convention
List<Account> accounts; // Plural noun
Set<Id> accountIds; // Type suffix
Map<Id, Account> accountsById; // Key description
Map<String, List<Contact>> contactsByEmail; // Nested collection
3. SOQL Best Practices
Always Use Selective Queries
// GOOD: Filter on indexed fields
[SELECT Id FROM Account WHERE Id = :recordId]
[SELECT Id FROM Account WHERE Name = :name]
[SELECT Id FROM Account WHERE CreatedDate > :startDate]
[SELECT Id FROM Contact WHERE Email = :email] // If External ID
// BAD: Non-selective patterns
[SELECT Id FROM Account WHERE Custom_Field__c != null] // != null
[SELECT Id FROM Account WHERE Name LIKE '%test%'] // Leading wildcard
[SELECT Id FROM Account WHERE CALENDAR_YEAR(CreatedDate) = 2025] // Function
Use Bind Variables (Prevent SOQL Injection)
// BAD: String concatenation (SOQL injection risk)
String query = 'SELECT Id FROM Account WHERE Name = '' + userInput + ''';
// GOOD: Bind variable
String query = 'SELECT Id FROM Account WHERE Name = :userInput';
List<Account> accounts = Database.query(query);
Use USER_MODE for Security
// Enforces CRUD/FLS automatically
List<Account> accounts = [SELECT Id, Name FROM Account WITH USER_MODE];
// For Database methods
Database.query(query, AccessLevel.USER_MODE);
Database.insert(records, AccessLevel.USER_MODE);
SOQL For Loops for Large Data
// Memory efficient - processes 200 records at a time
for (Account acc : [SELECT Id, Name FROM Account]) {
// Process each account
}
// Or batch processing
for (List<Account> batch : [SELECT Id, Name FROM Account]) {
// Process batch of 200
}
4. Governor Limits Awareness
Key Limits
| Limit | Synchronous | Asynchronous |
|---|---|---|
| SOQL Queries | 100 | 200 |
| DML Statements | 150 | 150 |
| CPU Time | 10,000 ms | 60,000 ms |
| Heap Size | 6 MB | 12 MB |
| Callouts | 100 | 100 |
Monitor Limits
System.debug('SOQL: ' + Limits.getQueries() + '/' + Limits.getLimitQueries());
System.debug('DML: ' + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements());
System.debug('CPU: ' + Limits.getCpuTime() + '/' + Limits.getLimitCpuTime());
System.debug('Heap: ' + Limits.getHeapSize() + '/' + Limits.getLimitHeapSize());
Heap Optimization
// BAD: Class-level variable holds large data
public class BadExample {
private List<Account> allAccounts; // Stays in memory
}
// GOOD: Let variables go out of scope
public class GoodExample {
public void process() {
List<Account> accounts = [SELECT Id FROM Account];
// Process...
} // accounts released here
}
5. Null Safety
Null Coalescing Operator (??)
// Old way
String value = input != null ? input : 'default';
// New way (API 60+)
String value = input ?? 'default';
// Chaining
String value = firstChoice ?? secondChoice ?? 'default';
Safe Navigation Operator (?.)
// Old way
String accountName = null;
if (contact != null && contact.Account != null) {
accountName = contact.Account.Name;
}
// New way
String accountName = contact?.Account?.Name;
// With null coalescing
String accountName = contact?.Account?.Name ?? 'Unknown';
Null-Safe Collection Access
// Check before accessing
if (myMap != null && myMap.containsKey(key)) {
return myMap.get(key);
}
// Or use getOrDefault pattern
public static Object getOrDefault(Map<Id, Object> m, Id key, Object defaultVal) {
return m?.containsKey(key) == true ? m.get(key) : defaultVal;
}
6. Error Handling
Catch Specific Exceptions
try {
insert accounts;
} catch (DmlException e) {
// Handle DML-specific errors
for (Integer i = 0; i < e.getNumDml(); i++) {
System.debug('Field: ' + e.getDmlFieldNames(i));
System.debug('Message: ' + e.getDmlMessage(i));
}
} catch (QueryException e) {
// Handle query errors
} catch (Exception e) {
// Generic fallback - log and rethrow
System.debug(LoggingLevel.ERROR, e.getMessage());
throw e;
}
Custom Exceptions
public class InsufficientInventoryException extends Exception {}
public void processOrder(Order ord) {
if (ord.Quantity__c > availableStock) {
throw new InsufficientInventoryException(
'Requested: ' + ord.Quantity__c + ', Available: ' + availableStock
);
}
}
AuraHandledException for LWC
@AuraEnabled
public static void processRecord(Id recordId) {
try {
// Business logic
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
7. Async Apex Selection
@future
// Simple, fire-and-forget
@future(callout=true)
public static void makeCallout(Set<Id> recordIds) {
// Cannot return value, cannot chain
}
Queueable
// Complex logic, can chain, can pass complex types
public class ProcessRecordsQueueable implements Queueable {
private List<Account> accounts;
public ProcessRecordsQueueable(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
// Process accounts
// Chain next job if needed
if (moreWork) {
System.enqueueJob(new ProcessRecordsQueueable(nextBatch));
}
}
}
Batch Apex
// Large data volumes (millions of records)
public class ProcessAccountsBatch implements Database.Batchable<SObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id FROM Account');
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
// Process batch (default 200 records)
}
public void finish(Database.BatchableContext bc) {
// Cleanup, notifications, chain next batch
}
}
8. Platform Cache
When to Use
- Frequently accessed, rarely changed data
- Expensive calculations
- Cross-transaction data sharing
Implementation
// Check cache first
Account acc = (Account)Cache.Org.get('local.AccountCache.' + accountId);
if (acc == null) {
acc = [SELECT Id, Name FROM Account WHERE Id = :accountId];
Cache.Org.put('local.AccountCache.' + accountId, acc, 3600); // 1 hour TTL
}
return acc;
Always Handle Cache Misses
// Cache can be evicted at any time
Object cachedValue = Cache.Org.get(key);
if (cachedValue == null) {
// Rebuild from source
}
9. Static Variables for Transaction Caching
Prevent Duplicate Queries
public class AccountService {
private static Map<Id, Account> accountCache;
public static Account getAccount(Id accountId) {
if (accountCache == null) {
accountCache = new Map<Id, Account>();
}
if (!accountCache.containsKey(accountId)) {
accountCache.put(accountId, [SELECT Id, Name FROM Account WHERE Id = :accountId]);
}
return accountCache.get(accountId);
}
}
Recursion Prevention
public class TriggerHelper {
private static Set<Id> processedIds = new Set<Id>();
public static Boolean hasProcessed(Id recordId) {
if (processedIds.contains(recordId)) {
return true;
}
processedIds.add(recordId);
return false;
}
}
10. Guard Clauses & Fail-Fast
💡 Principles inspired by "Clean Apex Code" by Pablo Gonzalez. Purchase the book for complete coverage.
The Problem
Deeply nested validation leads to hard-to-read code where business logic is buried.
Anti-Pattern
// BAD: Deep nesting obscures business logic
public void processAccountUpdate(Account oldAccount, Account newAccount) {
if (newAccount != null) {
if (oldAccount != null) {
if (newAccount.Id != null) {
if (hasFieldChanged(oldAccount, newAccount)) {
if (UserInfo.getUserType() == 'Standard') {
// Actual business logic buried 5 levels deep
performSync(newAccount);
sendNotification(newAccount);
}
}
}
}
}
}
Best Practice: Guard Clauses
// GOOD: Guard clauses at the top, exit early
public void processAccountUpdate(Account oldAccount, Account newAccount) {
// Guard clauses - validate preconditions and exit fast
if (newAccount == null) return;
if (oldAccount == null) return;
if (newAccount.Id == null) return;
if (!hasFieldChanged(oldAccount, newAccount)) return;
if (UserInfo.getUserType() != 'Standard') return;
// Main logic is now at the top level, clearly visible
performSync(newAccount);
sendNotification(newAccount);
}
Parameter Validation with Exceptions
For public APIs, throw exceptions for invalid input:
public Database.LeadConvertResult convertLead(Id leadId, Id accountId) {
// Guard clauses with exceptions for public API
if (leadId == null) {
throw new IllegalArgumentException('Lead ID cannot be null');
}
if (leadId.getSObjectType() != Lead.SObjectType) {
throw new IllegalArgumentException('Expected Lead ID, received: ' + leadId.getSObjectType());
}
Lead leadRecord = queryLead(leadId);
if (leadRecord == null) {
throw new IllegalArgumentException('Lead not found: ' + leadId);
}
if (leadRecord.IsConverted) {
throw new IllegalArgumentException('Lead is already converted: ' + leadId);
}
// Main conversion logic
return performConversion(leadRecord, accountId);
}
When to Use Each Pattern
| Scenario | Pattern | Example |
|---|---|---|
| Private/internal methods | return early |
if (list == null) return; |
| Public API | throw Exception |
throw new IllegalArgumentException(...) |
| Trigger handlers | return for skip |
if (records.isEmpty()) return; |
| Validation service | addError() |
record.addError('...') |
11. Comment Best Practices
💡 Principles inspired by "Clean Apex Code" by Pablo Gonzalez. Purchase the book for complete coverage.
Core Principle
Comments should explain "why", not "what". The code itself should communicate the "what".
When Comments Add Value
// GOOD: Explains business decision
// Salesforce processes triggers in batches of 200. We use 201 to ensure
// our code handles batch boundaries correctly during testing.
private static final Integer BULK_TEST_SIZE = 201;
// GOOD: Documents platform limitation
// Safe navigation (?.) doesn't work in formulas - must use IF(ISBLANK())
// See Known Issue W-12345678
// GOOD: References external documentation
// Algorithm based on RFC 7519 (JSON Web Token specification)
// See: https://tools.ietf.org/html/rfc7519#section-4.1
// GOOD: Explains non-obvious optimization
// DML on empty list still consumes ~10x CPU. Always check isEmpty().
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
Comment Anti-Patterns
// BAD: Restates what code clearly shows
Integer count = 0; // Initialize count to zero
// BAD: Version history belongs in Git
// Modified by John on 2024-01-15 to add validation
// Modified by Jane on 2024-02-20 to fix bug
// BAD: Commented-out code (delete it!)
// if (account.Type == 'Partner') {
// processPartner(account);
// }
// BAD: TODO without owner or ticket
// TODO: fix this later
// GOOD: TODO with context
// TODO(JIRA-1234): Refactor to use Platform Events after Spring '26 release
Self-Documenting Code
Instead of comments, make code self-explanatory:
// BAD: Needs comment to explain
if (acc.AnnualRevenue > 1000000 && acc.Type == 'Enterprise' && acc.Industry == 'Technology') {
// Process strategic tech accounts
}
// GOOD: Code explains itself
Boolean isStrategicTechAccount =
acc.AnnualRevenue > 1000000 &&
acc.Type == 'Enterprise' &&
acc.Industry == 'Technology';
if (isStrategicTechAccount) {
processStrategicAccount(acc);
}
12. DML Performance Pattern
💡 Principles inspired by "Clean Apex Code" by Pablo Gonzalez. Purchase the book for complete coverage.
The Problem
DML operations on empty collections still consume significant CPU time (~10x more than checking isEmpty first).
Anti-Pattern
// BAD: DML on potentially empty list
List<Account> accountsToUpdate = new List<Account>();
// ... conditional logic that might not add anything ...
update accountsToUpdate; // Wastes CPU even if empty
Best Practice
// GOOD: Always check isEmpty() before DML
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
SafeDML Wrapper
Create a utility class to enforce this pattern:
public class SafeDML {
public static Database.SaveResult[] safeInsert(List<SObject> records) {
if (records == null || records.isEmpty()) {
return new List<Database.SaveResult>();
}
return Database.insert(records, false);
}
public static Database.SaveResult[] safeUpdate(List<SObject> records) {
if (records == null || records.isEmpty()) {
return new List<Database.SaveResult>();
}
return Database.update(records, false);
}
public static Database.DeleteResult[] safeDelete(List<SObject> records) {
if (records == null || records.isEmpty()) {
return new List<Database.DeleteResult>();
}
return Database.delete(records, false);
}
public static Database.UpsertResult[] safeUpsert(
List<SObject> records,
Schema.SObjectField externalIdField
) {
if (records == null || records.isEmpty()) {
return new List<Database.UpsertResult>();
}
return Database.upsert(records, externalIdField, false);
}
}
Usage
// Clean, safe DML operations
SafeDML.safeInsert(newAccounts);
SafeDML.safeUpdate(modifiedContacts);
SafeDML.safeDelete(obsoleteRecords);
Performance Impact
| Scenario | CPU Time |
|---|---|
update emptyList |
~100-200 CPU ms |
if (!empty) update |
~10-20 CPU ms |
| Savings | ~10x improvement |
In triggers processing many records, this optimization compounds significantly.