# Apex Best Practices Reference

> Apex triggers can process up to 200 records at once. Code that works for single records often fails at scale.

- Skill: `tools-only/apex-best-practices-reference` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add tools-only/apex-best-practices-reference`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tools-only/apex-best-practices-reference/raw
- Safety review: pending (external: skill-scanner PASS, skillspector CAUTION)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tools-only (https://skillmd.com/u/tools-only)
- Updated: 2026-09-29
- Page: https://skillmd.com/skills/tools-only/apex-best-practices-reference

---

<!-- Parent: sf-apex/SKILL.md -->
# 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
```apex
// 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
```apex
// 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
```apex
// 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
```apex
@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
```apex
// 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
```apex
// 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
```apex
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
```apex
// 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)
```apex
// 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
```apex
// 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
```apex
// 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
```apex
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
```apex
// 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 (??)
```apex
// 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 (?.)
```apex
// 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
```apex
// 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
```apex
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
```apex
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
```apex
@AuraEnabled
public static void processRecord(Id recordId) {
    try {
        // Business logic
    } catch (Exception e) {
        throw new AuraHandledException(e.getMessage());
    }
}
```

---

## 7. Async Apex Selection

### @future
```apex
// Simple, fire-and-forget
@future(callout=true)
public static void makeCallout(Set<Id> recordIds) {
    // Cannot return value, cannot chain
}
```

### Queueable
```apex
// 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
```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
```apex
// 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
```apex
// 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
```apex
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
```apex
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](https://link.springer.com/book/10.1007/979-8-8688-1411-2) for complete coverage.*

### The Problem

Deeply nested validation leads to hard-to-read code where business logic is buried.

### Anti-Pattern
```apex
// 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
```apex
// 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:

```apex
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](https://link.springer.com/book/10.1007/979-8-8688-1411-2) for complete coverage.*

### Core Principle

Comments should explain **"why"**, not **"what"**. The code itself should communicate the "what".

### When Comments Add Value

```apex
// 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

```apex
// 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:

```apex
// 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](https://link.springer.com/book/10.1007/979-8-8688-1411-2) for complete coverage.*

### The Problem

DML operations on empty collections still consume significant CPU time (~10x more than checking isEmpty first).

### Anti-Pattern
```apex
// 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
```apex
// GOOD: Always check isEmpty() before DML
if (!accountsToUpdate.isEmpty()) {
    update accountsToUpdate;
}
```

### SafeDML Wrapper

Create a utility class to enforce this pattern:

```apex
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
```apex
// 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.

