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