All skills
Skillintermediate

Apex Development

Separate business logic from triggers and controllers using service classes.

Claude Code Knowledge Pack7/10/2026

Overview

Apex Development


Apex Class Structure

Service Layer Pattern

Separate business logic from triggers and controllers using service classes.

/**
 * AccountService - Business logic for Account operations
 * Follows Single Responsibility Principle
 */
public with sharing class AccountService {

    /**
     * Updates account ratings based on opportunity history
     * @param accountIds Set of Account IDs to process
     * @throws AccountServiceException on validation failure
     */
    public static void updateAccountRatings(Set accountIds) {
        if (accountIds == null || accountIds.isEmpty()) {
            return;
        }

        // Bulkified query - single SOQL for all records
        Map<Id, Account> accountsToUpdate = new Map<Id, Account>();

        for (Account acc : [
            SELECT Id, Rating,
                   (SELECT Amount, StageName FROM Opportunities
                    WHERE StageName = 'Closed Won')
            FROM Account
            WHERE Id IN :accountIds
        ]) {
            Decimal totalRevenue = 0;
            for (Opportunity opp : acc.Opportunities) {
                totalRevenue += opp.Amount != null ? opp.Amount : 0;
            }

            String newRating = calculateRating(totalRevenue);
            if (acc.Rating != newRating) {
                accountsToUpdate.put(acc.Id, new Account(
                    Id = acc.Id,
                    Rating = newRating
                ));
            }
        }

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

    private static String calculateRating(Decimal revenue) {
        if (revenue >= 1000000) return 'Hot';
        if (revenue >= 100000) return 'Warm';
        return 'Cold';
    }
}

Domain Layer Pattern

Encapsulate object-specific logic in domain classes.

/**
 * Accounts Domain Class
 * Encapsulates Account-specific business rules
 */
public with sharing class Accounts {

    private List records;

    public Accounts(List records) {
        this.records = records;
    }

    public static Accounts newInstance(List records) {
        return new Accounts(records);
    }

    /**
     * Validates accounts before insert/update
     * @return List of validation errors
     */
    public List validate() {
        List errors = new List();

        for (Account acc : records) {
            if (String.isBlank(acc.Name)) {
                errors.add('Account Name is required');
            }
            if (acc.AnnualRevenue != null && acc.AnnualRevenue < 0) {
                errors.add('Annual Revenue cannot be negative');
            }
        }

        return errors;
    }

    /**
     * Sets default values for new accounts
     */
    public void setDefaults() {
        for (Account acc : records) {
            if (String.isBlank(acc.Rating)) {
                acc.Rating = 'Cold';
            }
            if (acc.NumberOfEmployees == null) {
                acc.NumberOfEmployees = 0;
            }
        }
    }
}

Trigger Framework

Handler Pattern

Never put logic directly in triggers. Use a handler framework.

/**
 * AccountTrigger - Delegates all logic to handler
 */
trigger AccountTrigger on Account (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    AccountTriggerHandler handler = new AccountTriggerHandler();

    switch on Trigger.operationType {
        when BEFORE_INSERT {
            handler.beforeInsert(Trigger.new);
        }
        when BEFORE_UPDATE {
            handler.beforeUpdate(Trigger.new, Trigger.oldMap);
        }
        when BEFORE_DELETE {
            handler.beforeDelete(Trigger.old, Trigger.oldMap);
        }
        when AFTER_INSERT {
            handler.afterInsert(Trigger.new, Trigger.newMap);
        }
        when AFTER_UPDATE {
            handler.afterUpdate(Trigger.new, Trigger.newMap, Trigger.old, Trigger.oldMap);
        }
        when AFTER_DELETE {
            handler.afterDelete(Trigger.old, Trigger.oldMap);
        }
        when AFTER_UNDELETE {
            handler.afterUndelete(Trigger.new, Trigger.newMap);
        }
    }
}

Trigger Handler Class

/**
 * AccountTriggerHandler - Contains all trigger logic
 * Implements recursion prevention and bulkification
 */
public with sharing class AccountTriggerHandler {

    // Recursion prevention
    private static Boolean isExecuting = false;
    private static Set processedIds = new Set();

    public void beforeInsert(List newRecords) {
        Accounts domain = Accounts.newInstance(newRecords);
        domain.setDefaults();

        List errors = domain.validate();
        if (!errors.isEmpty()) {
            for (Account acc : newRecords) {
                acc.addError(String.join(errors, '; '));
            }
        }
    }

    public void beforeUpdate(List newRecords, Map<Id, Account> oldMap) {
        // Field change detection
        for (Account acc : newRecords) {
            Account oldAcc = oldMap.get(acc.Id);

            if (acc.OwnerId != oldAcc.OwnerId) {
                // Owner changed - track for audit
                acc.Owner_Changed_Date__c = System.now();
            }
        }
    }

    public void afterInsert(List newRecords, Map<Id, Account> newMap) {
        if (isExecuting) return;
        isExecuting = true;

        try {
            // Create default contacts for new accounts
            createDefaultContacts(newRecords);
        } finally {
            isExecuting = false;
        }
    }

    public void afterUpdate(
        List newRecords,
        Map<Id, Account> newMap,
        List oldRecords,
        Map<Id, Account> oldMap
    ) {
        // Filter to only process records not already handled
        List toProcess = new List();
        for (Account acc : newRecords) {
            if (!processedIds.contains(acc.Id)) {
                toProcess.add(acc);
                processedIds.add(acc.Id);
            }
        }

        if (!toProcess.isEmpty()) {
            AccountService.updateAccountRatings(new Map<Id, Account>(toProcess).keySet());
        }
    }

    public void beforeDelete(List oldRecords, Map<Id, Account> oldMap) {
        // Prevent deletion of accounts with open opportunities
        Set accountIds = oldMap.keySet();
        Map<Id, Integer> openOppCounts = new Map<Id, Integer>();

        for (AggregateResult ar : [
            SELECT AccountId, COUNT(Id) cnt
            FROM Opportunity
            WHERE AccountId IN :accountIds
            AND IsClosed = false
            GROUP BY AccountId
        ]) {
            openOppCounts.put((Id)ar.get('AccountId'), (Integer)ar.get('cnt'));
        }

        for (Account acc : oldRecords) {
            if (openOppCounts.containsKey(acc.Id) && openOppCounts.get(acc.Id) > 0) {
                acc.addError('Cannot delete account with open opportunities');
            }
        }
    }

    public void afterDelete(List oldRecords, Map<Id, Account> oldMap) {
        // Audit logging for deleted accounts
        List<Account_Audit__c> auditRecords = new List<Account_Audit__c>();
        for (Account acc : oldRecords) {
            auditRecords.add(new Account_Audit__c(
                Account_Name__c = acc.Name,
                Action__c = 'Deleted',
                Deleted_Date__c = System.now(),
                Deleted_By__c = UserInfo.getUserId()
            ));
        }

        if (!auditRecords.isEmpty()) {
            insert auditRecords;
        }
    }

    public void afterUndelete(List newRecords, Map<Id, Account> newMap) {
        // Handle undelete scenarios
    }

    private void createDefaultContacts(List accounts) {
        List contacts = new List();
        for (Account acc : accounts) {
            contacts.add(new Contact(
                AccountId = acc.Id,
                LastName = 'Primary Contact',
                Email = 'primary@' + acc.Name.toLowerCase().replaceAll('[^a-z0-9]', '') + '.com'
            ));
        }

        if (!contacts.isEmpty()) {
            insert contacts;
        }
    }
}

Asynchronous Apex Patterns

When to Use Each Pattern

PatternUse CaseLimits
FutureSimple async callout, quick operations50 calls per transaction
QueueableChaining jobs, complex async logic50 jobs per transaction
BatchProcessing large data volumes5 active batches
ScheduledTime-based execution100 scheduled jobs

Future Methods

Use for simple callouts or operations that don't need chaining.

public class AccountIntegration {

    /**
     * Sends account data to external system
     * @param accountIds Set of Account IDs to sync
     */
    @future(callout=true)
    public static void syncToExternalSystem(Set accountIds) {
        if (accountIds == null || accountIds.isEmpty()) {
            return;
        }

        List accounts = [
            SELECT Id, Name, BillingCity, BillingCountry, Industry
            FROM Account
            WHERE Id IN :accountIds
        ];

        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setEndpoint('callout:External_System/api/accounts');
        request.setMethod('POST');
        request.setHeader('Content-Type', 'application/json');
        request.setBody(JSON.serialize(accounts));

        try {
            HttpResponse response = http.send(request);
            if (response.getStatusCode() != 200) {
                System.debug(LoggingLevel.ERROR,
                    'Sync failed: ' + response.getStatusCode() + ' ' + response.getBody());
            }
        } catch (Exception e) {
            System.debug(LoggingLevel.ERROR, 'Callout exception: ' + e.getMessage());
        }
    }
}

Queueable Apex

Use for job chaining and passing complex data types.

/**
 * Queueable job for processing account hierarchies
 * Supports job chaining for large datasets
 */
public class AccountHierarchyProcessor implements Queueable, Database.AllowsCallouts {

    private List accountIds;
    private Integer depth;
    private static final Integer MAX_DEPTH = 5;
    private static final Integer BATCH_SIZE = 200;

    public AccountHierarchyProcessor(List accountIds, Integer depth) {
        this.accountIds = accountIds;
        this.depth = depth;
    }

    public void execute(QueueableContext context) {
        // Process current batch
        List accounts = [
            SELECT Id, Name, ParentId, Ultimate_Parent__c
            FROM Account
            WHERE Id IN :accountIds
        ];

        Set childAccountIds = new Set();
        List toUpdate = new List();

        for (Account acc : accounts) {
            if (acc.ParentId != null) {
                acc.Ultimate_Parent__c = findUltimateParent(acc.ParentId);
                toUpdate.add(acc);
            }

            // Collect child accounts for next iteration
            for (Account child : [
                SELECT Id FROM Account WHERE ParentId = :acc.Id
            ]) {
                childAccountIds.add(child.Id);
            }
        }

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

        // Chain next job if there are child accounts and within depth limit
        if (!childAccountIds.isEmpty() && depth < MAX_DEPTH) {
            List nextBatch = new List(childAccountIds);
            if (nextBatch.size() > BATCH_SIZE) {
                nextBatch = new List();
                Integer count = 0;
                for (Id accId : childAccountIds) {
                    if (count++ >= BATCH_SIZE) break;
                    nextBatch.add(accId);
                }
            }

            if (!Test.isRunningTest()) {
                System.enqueueJob(new AccountHierarchyProcessor(nextBatch, depth + 1));
            }
        }
    }

    private Id findUltimateParent(Id parentId) {
        Account current = [SELECT Id, ParentId FROM Account WHERE Id = :parentId];
        while (current.ParentId != null) {
            current = [SELECT Id, ParentId FROM Account WHERE Id = :current.ParentId];
        }
        return current.Id;
    }
}

// Usage
// System.enqueueJob(new AccountHierarchyProcessor(accountIds, 0));

Batch Apex

Use for processing large data volumes (millions of records).

/**
 * Batch job for annual account cleanup
 * Processes inactive accounts in configurable batch sizes
 */
public class AccountCleanupBatch implements
    Database.Batchable,
    Database.Stateful,
    Database.AllowsCallouts {

    private Integer successCount = 0;
    private Integer failureCount = 0;
    private List errors = new List();
    private Date cutoffDate;

    public AccountCleanupBatch() {
        this.cutoffDate = Date.today().addYears(-2);
    }

    public AccountCleanupBatch(Date cutoffDate) {
        this.cutoffDate = cutoffDate;
    }

    /**
     * Query locator - defines records to process
     * Governor limit: 50 million records max
     */
    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([
            SELECT Id, Name, LastActivityDate,
                   (SELECT Id FROM Opportunities WHERE IsClosed = false LIMIT 1)
            FROM Account
            WHERE LastActivityDate < :cutoffDate
            AND IsActive__c = true
        ]);
    }

    /**
     * Execute - processes each batch of records
     * Default batch size: 200, configurable up to 2000
     */
    public void execute(Database.BatchableContext bc, List scope) {
        List toDeactivate = new List();

        for (Account acc : scope) {
            // Skip accounts with open opportunities
            if (acc.Opportunities != null && !acc.Opportunities.isEmpty()) {
                continue;
            }

            toDeactivate.add(new Account(
                Id = acc.Id,
                IsActive__c = false,
                Deactivated_Date__c = Date.today(),
                Deactivated_Reason__c = 'No activity for 2+ years'
            ));
        }

        if (!toDeactivate.isEmpty()) {
            Database.SaveResult[] results = Database.update(toDeactivate, false);

            for (Integer i = 0; i < results.size(); i++) {
                if (results[i].isSuccess()) {
                    successCount++;
                } else {
                    failureCount++;
                    for (Database.Error err : results[i].getErrors()) {
                        errors.add(toDeactivate[i].Id + ': ' + err.getMessage());
                    }