---
title: "Apex Development"
description: "Separate business logic from triggers and controllers using service classes."
type: skill
canonical_url: https://claudary.paisolsolutions.com/skills/apex-development
source: "Claudary"
difficulty: intermediate
author: "Claude Code Knowledge Pack"
date: 2026-07-10T11:07:38.167Z
license: CC-BY-4.0
attribution: "Apex Development — Claudary (https://claudary.paisolsolutions.com/skills/apex-development)"
---

# Apex Development
Separate business logic from triggers and controllers using service classes.

## Overview

# Apex Development

---

## Apex Class Structure

### Service Layer Pattern

Separate business logic from triggers and controllers using service classes.

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

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

    private List<Account> records;

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

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

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

        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.

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

```apex
/**
 * 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<Id> processedIds = new Set<Id>();

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

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

    public void beforeUpdate(List<Account> 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<Account> 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<Account> newRecords,
        Map<Id, Account> newMap,
        List<Account> oldRecords,
        Map<Id, Account> oldMap
    ) {
        // Filter to only process records not already handled
        List<Account> toProcess = new List<Account>();
        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<Account> oldRecords, Map<Id, Account> oldMap) {
        // Prevent deletion of accounts with open opportunities
        Set<Id> 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<Account> 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<Account> newRecords, Map<Id, Account> newMap) {
        // Handle undelete scenarios
    }

    private void createDefaultContacts(List<Account> accounts) {
        List<Contact> contacts = new List<Contact>();
        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

| Pattern | Use Case | Limits |
|---------|----------|--------|
| **Future** | Simple async callout, quick operations | 50 calls per transaction |
| **Queueable** | Chaining jobs, complex async logic | 50 jobs per transaction |
| **Batch** | Processing large data volumes | 5 active batches |
| **Scheduled** | Time-based execution | 100 scheduled jobs |

### Future Methods

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

```apex
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<Id> accountIds) {
        if (accountIds == null || accountIds.isEmpty()) {
            return;
        }

        List<Account> 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.

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

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

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

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

        Set<Id> childAccountIds = new Set<Id>();
        List<Account> toUpdate = new List<Account>();

        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<Id> nextBatch = new List<Id>(childAccountIds);
            if (nextBatch.size() > BATCH_SIZE) {
                nextBatch = new List<Id>();
                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).

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

    private Integer successCount = 0;
    private Integer failureCount = 0;
    private List<String> errors = new List<String>();
    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<Account> scope) {
        List<Account> toDeactivate = new List<Account>();

        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());
                    }

---

Source: [Claudary](https://claudary.paisolsolutions.com/skills/apex-development) · https://claudary.paisolsolutions.com
