afv-library/skills/generating-apex/references/bulkification-guide.md

14 KiB

Apex Bulkification Guide

Comprehensive guide to writing bulk-safe Apex code, understanding governor limits, and optimizing collection handling.


Table of Contents

  1. Governor Limits Overview
  2. The Golden Rules
  3. Common Bulkification Patterns
  4. Collection Handling Best Practices
  5. Monitoring and Debugging

Governor Limits Overview

Salesforce enforces per-transaction limits to ensure multi-tenant platform stability.

Critical Limits (Synchronous Context)

Resource Limit Notes
SOQL Queries 100 Includes parent-child queries
SOQL Query Rows 50,000 Total rows retrieved
DML Statements 150 insert, update, delete, undelete operations
DML Rows 10,000 Total records per transaction
CPU Time 10,000ms Actual CPU time (not wall clock)
Heap Size 6 MB Memory used by variables
Callouts 100 HTTP requests
Callout Time 120 seconds Total time for all callouts

Asynchronous Limits (Future, Batch, Queueable)

Resource Limit Notes
SOQL Queries 200 Double synchronous
SOQL Query Rows 50,000 Same as sync
DML Statements 150 Same as sync
DML Rows 10,000 Same as sync
CPU Time 60,000ms 6x synchronous
Heap Size 12 MB 2x synchronous

Key Insight: Async has more SOQL queries and CPU time, but DML limits are the same.


The Golden Rules

The hard rules (no SOQL in loops, no DML in loops, collection-first design) are defined in SKILL.md § Bulkification & Governor Limits and § Never Generate These. This section provides the implementation patterns for each rule.

Pattern: Collect → Query → Map → Loop

Replace per-record SOQL with a single bulk query and a Map lookup:

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

Map<Id, List<Contact>> contactsByAccountId = new Map<Id, List<Contact>>();
for (Contact con : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
    if (!contactsByAccountId.containsKey(con.AccountId)) {
        contactsByAccountId.put(con.AccountId, new List<Contact>());
    }
    contactsByAccountId.get(con.AccountId).add(con);
}

for (Account acc : accounts) {
    List<Contact> contacts = contactsByAccountId.get(acc.Id) ?? new List<Contact>();
    // Process contacts
}

Pattern: Modify in Loop → DML After Loop

Collect changes in a List, then perform a single DML statement:

List<Account> toUpdate = new List<Account>();
for (Account acc : accounts) {
    if (acc.Industry != 'Technology') {
        toUpdate.add(new Account(Id = acc.Id, Industry = 'Technology'));
    }
}
update toUpdate;

Pattern: Relationship Subqueries

Fetch parent and child records in a single SOQL:

Map<Id, Account> accountsWithRelated = new Map<Id, Account>([
    SELECT Id, Name,
           (SELECT Id FROM Contacts),
           (SELECT Id FROM Opportunities)
    FROM Account
    WHERE Id IN :accountIds
]);

Common Bulkification Patterns

Pattern 1: Map-Based Lookup

Use Case: Need to lookup related records for each item in a loop.

public static void updateAccountIndustry(List<Contact> contacts) {
    // Step 1: Collect Account IDs
    Set<Id> accountIds = new Set<Id>();
    for (Contact con : contacts) {
        if (con.AccountId != null) {
            accountIds.add(con.AccountId);
        }
    }

    // Step 2: Query Accounts into Map
    Map<Id, Account> accountMap = new Map<Id, Account>([
        SELECT Id, Industry
        FROM Account
        WHERE Id IN :accountIds
    ]);

    // Step 3: Loop and lookup
    for (Contact con : contacts) {
        Account acc = accountMap.get(con.AccountId);
        if (acc != null) {
            con.Description = 'Account Industry: ' + acc.Industry;
        }
    }

    update contacts;
}

Key: Map<Id, SObject> constructor automatically creates map from query results.


Use Case: Process child records grouped by parent.

public static void processContactsByAccount(List<Contact> contacts) {
    // Group contacts by AccountId
    Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();

    for (Contact con : contacts) {
        if (!contactsByAccount.containsKey(con.AccountId)) {
            contactsByAccount.put(con.AccountId, new List<Contact>());
        }
        contactsByAccount.get(con.AccountId).add(con);
    }

    // Process each group
    for (Id accountId : contactsByAccount.keySet()) {
        List<Contact> accountContacts = contactsByAccount.get(accountId);
        System.debug('Account ' + accountId + ' has ' + accountContacts.size() + ' contacts');
        // Process accountContacts
    }
}

Alternative using Null Coalescing (API 59+):

for (Contact con : contacts) {
    List<Contact> existing = contactsByAccount.get(con.AccountId);
    if (existing == null) {
        existing = new List<Contact>();
        contactsByAccount.put(con.AccountId, existing);
    }
    existing.add(con);
}

Pattern 3: Aggregate Queries for Rollups

Use Case: Calculate rollup values (count, sum, avg) on related records.

public static void updateAccountContactCounts(Set<Id> accountIds) {
    // Query aggregate data
    Map<Id, Integer> contactCountsByAccount = new Map<Id, Integer>();

    for (AggregateResult ar : [
        SELECT AccountId, COUNT(Id) contactCount
        FROM Contact
        WHERE AccountId IN :accountIds
        GROUP BY AccountId
    ]) {
        Id accountId = (Id) ar.get('AccountId');
        Integer count = (Integer) ar.get('contactCount');
        contactCountsByAccount.put(accountId, count);
    }

    // Update accounts
    List<Account> accountsToUpdate = new List<Account>();
    for (Id accountId : accountIds) {
        Integer count = contactCountsByAccount.get(accountId) ?? 0;
        accountsToUpdate.add(new Account(
            Id = accountId,
            Number_of_Contacts__c = count
        ));
    }

    update accountsToUpdate;
}

Why use aggregates: More efficient than querying all records and counting in Apex.


Pattern 4: Bulk Upsert with External ID

Use Case: Upserting records from external system.

public static void syncAccountsFromExternal(List<ExternalAccount> externalAccounts) {
    List<Account> accountsToUpsert = new List<Account>();

    for (ExternalAccount ext : externalAccounts) {
        accountsToUpsert.add(new Account(
            External_ID__c = ext.externalId,  // External ID field
            Name = ext.name,
            Industry = ext.industry
        ));
    }

    // Upsert by External ID field
    Database.upsert(accountsToUpsert, Account.External_ID__c, false);
}

Key: Database.upsert() with External ID field automatically matches and updates existing records.


Pattern 5: Conditional DML (Only Update Changed Records)

Use Case: Avoid unnecessary DML on unchanged records.

public static void updateAccountsIfChanged(List<Account> accounts, Map<Id, Account> oldMap) {
    List<Account> accountsToUpdate = new List<Account>();

    for (Account newAcc : accounts) {
        Account oldAcc = oldMap.get(newAcc.Id);

        // Only update if specific fields changed
        if (newAcc.Industry != oldAcc.Industry || newAcc.Rating != oldAcc.Rating) {
            accountsToUpdate.add(newAcc);
        }
    }

    if (!accountsToUpdate.isEmpty()) {
        update accountsToUpdate;
    }
}

Benefit: Reduces DML statements and CPU time.


Collection Handling Best Practices

Use the Right Collection Type

Collection When to Use Key Features
List Ordered data, duplicates allowed Index access, iteration
Set Unique values, fast lookups No duplicates, O(1) contains()
Map<K,V> Key-value pairs, fast lookups O(1) get(), unique keys

Example: Deduplication

// ❌ BAD - O(n²) complexity
List<Id> uniqueIds = new List<Id>();
for (Id accountId : allAccountIds) {
    if (!uniqueIds.contains(accountId)) {  // Linear search!
        uniqueIds.add(accountId);
    }
}

// ✅ GOOD - O(n) complexity
Set<Id> uniqueIdsSet = new Set<Id>(allAccountIds);  // Automatic deduplication

List Operations

Creating Lists:

// Empty list
List<Account> accounts = new List<Account>();

// From SOQL
List<Account> accounts = [SELECT Id FROM Account];

// From Set
Set<Id> idSet = new Set<Id>{acc1.Id, acc2.Id};
List<Id> idList = new List<Id>(idSet);

Adding Elements:

accounts.add(newAccount);           // Add single
accounts.addAll(moreAccounts);      // Add list

Set Operations

Union, Intersection, Difference:

Set<Id> set1 = new Set<Id>{id1, id2, id3};
Set<Id> set2 = new Set<Id>{id2, id3, id4};

// Union (all unique values)
Set<Id> union = set1.clone();
union.addAll(set2);  // {id1, id2, id3, id4}

// Intersection (common values)
Set<Id> intersection = set1.clone();
intersection.retainAll(set2);  // {id2, id3}

// Difference (in set1 but not set2)
Set<Id> difference = set1.clone();
difference.removeAll(set2);  // {id1}

Checking Membership:

if (accountIds.contains(acc.Id)) {
    // Fast O(1) lookup
}

⚠️ API 62.0 Breaking Change: Cannot modify Set while iterating - throws System.FinalException.

// ❌ FAILS in API 62.0+
Set<Id> ids = new Set<Id>{id1, id2, id3};
for (Id currentId : ids) {
    ids.add(newId);  // FinalException!
}

// ✅ GOOD - Collect changes, apply after loop
Set<Id> ids = new Set<Id>{id1, id2, id3};
Set<Id> toAdd = new Set<Id>();

for (Id currentId : ids) {
    toAdd.add(newId);
}

ids.addAll(toAdd);

Map Operations

Creating Maps:

// Empty map
Map<Id, Account> accountMap = new Map<Id, Account>();

// From List (uses SObject Id as key)
Map<Id, Account> accountMap = new Map<Id, Account>([SELECT Id, Name FROM Account]);

// Manual insertion
Map<String, Integer> scoreMap = new Map<String, Integer>();
scoreMap.put('Alice', 95);
scoreMap.put('Bob', 87);

Safe Access with Null Coalescing:

// Old way
Integer score = scoreMap.get('Charlie');
if (score == null) {
    score = 0;
}

// Modern way (API 59+)
Integer score = scoreMap.get('Charlie') ?? 0;

Iterating Maps:

// Iterate keys
for (Id accountId : accountMap.keySet()) {
    Account acc = accountMap.get(accountId);
}

// Iterate values
for (Account acc : accountMap.values()) {
    System.debug(acc.Name);
}

// Iterate entries (best for both key + value)
for (Id accountId : accountMap.keySet()) {
    Account acc = accountMap.get(accountId);
    System.debug('Account ' + accountId + ': ' + acc.Name);
}

Monitoring and Debugging

Using Limits Class

Check current consumption:

System.debug('SOQL Queries: ' + Limits.getQueries() + '/' + Limits.getLimitQueries());
System.debug('DML Statements: ' + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements());
System.debug('CPU Time: ' + Limits.getCpuTime() + '/' + Limits.getLimitCpuTime());
System.debug('Heap Size: ' + Limits.getHeapSize() + '/' + Limits.getLimitHeapSize());

Strategic placement:

public static void expensiveOperation() {
    System.debug('=== BEFORE OPERATION ===');
    logLimits();

    // Expensive code
    List<Account> accounts = [SELECT Id FROM Account];

    System.debug('=== AFTER OPERATION ===');
    logLimits();
}

private static void logLimits() {
    System.debug('SOQL: ' + Limits.getQueries() + '/' + Limits.getLimitQueries());
    System.debug('DML: ' + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements());
}

Debug Logs Best Practices

Use log levels strategically:

System.debug(LoggingLevel.ERROR, 'Critical failure: ' + errorMsg);
System.debug(LoggingLevel.WARN, 'Warning: potential issue');
System.debug(LoggingLevel.INFO, 'Processing ' + accounts.size() + ' accounts');
System.debug(LoggingLevel.DEBUG, 'Variable value: ' + variable);
System.debug(LoggingLevel.FINE, 'Detailed trace info');

Filter in Setup → Debug Logs:

  • Apex Code: DEBUG
  • Database: INFO
  • Workflow: INFO
  • Validation: INFO

Avoid excessive debug statements - they consume heap and CPU.


Query Plan Analysis

Check query selectivity:

// Use EXPLAIN in Developer Console or Workbench
// Or query plan API (requires REST call)

Indicators of bad queries:

  • TableScan (full table scan)
  • Cardinality mismatch (estimated vs actual rows)
  • Missing indexes on WHERE clause fields

For Apex testing patterns (including bulk scenarios), use generating-apex-test skill.

Advanced Optimization Techniques

Lazy Loading Pattern

Defer expensive operations until needed:

public class AccountProcessor {

    private Map<Id, List<Contact>> contactsCache;

    public List<Contact> getContactsForAccount(Id accountId) {
        // Lazy load - only query when first accessed
        if (contactsCache == null) {
            loadAllContacts();
        }

        return contactsCache.get(accountId) ?? new List<Contact>();
    }

    private void loadAllContacts() {
        contactsCache = new Map<Id, List<Contact>>();

        for (Contact con : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
            if (!contactsCache.containsKey(con.AccountId)) {
                contactsCache.put(con.AccountId, new List<Contact>());
            }
            contactsCache.get(con.AccountId).add(con);
        }
    }
}

Platform Cache for Expensive Queries

Cache frequently accessed data:

public class CachedMetadataService {

    private static final String CACHE_PARTITION = 'local.MetadataCache';

    public static List<Config__c> getConfigurations() {
        // Try cache first
        List<Config__c> cached = (List<Config__c>) Cache.Org.get(CACHE_PARTITION + '.configs');

        if (cached != null) {
            return cached;
        }

        // Cache miss - query and store
        List<Config__c> configs = [SELECT Id, Name, Value__c FROM Config__c];
        Cache.Org.put(CACHE_PARTITION + '.configs', configs, 3600); // 1 hour TTL

        return configs;
    }
}

Back to Main: SKILL.md