
Sf Apex
- 45 installs
- 12 repo stars
- Updated July 14, 2026
- clientell-ai/salesforce-skills
sf-apex is an agent skill that implements Salesforce Apex via a TriggerHandler framework and handler subclasses.
About
sf-apex is an agent skill that supplies Salesforce Apex design patterns centered on a reusable TriggerHandler framework. Solo builders and small teams customizing Salesforce orgs—or building ISV-style packages on the platform—use it when triggers grow unmaintainable or test bypass becomes ad hoc. The reference base class routes Trigger context (before/after, insert/update/delete/undelete) into overridable hooks, while static bypass helpers let tests and data jobs skip handlers safely. Concrete handlers like AccountTriggerHandler illustrate how to layer domain logic with sharing keywords. The skill is reference-oriented: it accelerates consistent trigger architecture during feature work and supports safer refactors before deployment. It does not replace Salesforce security review or governor-limit tuning but gives agents a canonical structure CRM backends expect.
- Virtual TriggerHandler base with before/after insert-update-delete-undelete routing
- Test-visible bypass set for unit tests and bulk data loads without firing handlers
- Handler implementation pattern extending base with sharing enforced on concrete classes
- Reference AccountTriggerHandler-style structure for real org customization
- Designed to replace monolithic triggers with one-handler-per-object discipline
Sf Apex by the numbers
- 45 all-time installs (skills.sh)
- Ranked #3,257 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/clientell-ai/salesforce-skills --skill sf-apexAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 12 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 14, 2026 |
| Repository | clientell-ai/salesforce-skills ↗ |
What it does
Implement Salesforce Apex triggers and services using a testable TriggerHandler framework instead of one-off trigger logic.
Who is it for?
Best when you're adding or refactoring Apex triggers on standard or custom objects in shared orgs.
Skip if: Flows-only automation with no Apex, or greenfield teams that need full security, limits, and deployment checklist docs rather than trigger patterns alone.
When should I use this skill?
Authoring or refactoring Salesforce Apex triggers, implementing handler subclasses, or needing test bypass for triggers.
What you get
Triggers delegate to a structured handler hierarchy with bypass support so features stay modular and tests stay deterministic.
- TriggerHandler base and object-specific handler classes
- Trigger thin delegator file per object
- Test patterns using bypass/clearBypass helpers
Files
Apex Code Generator & Reviewer
You are a Salesforce Apex specialist. Generate production-ready Apex code following all Salesforce best practices.
Code Generation Rules
Governor Limits Awareness
- NEVER put SOQL queries inside loops — bulkify by querying before the loop
- NEVER put DML statements inside loops — collect records in a List, then perform DML once
- Use
Limits.getQueries()andLimits.getLimitQueries()for monitoring - Prefer
Database.query()with bind variables over hardcoded SOQL strings - Use
System.QueueableorDatabase.Batchablefor large data operations
Security (CRUD/FLS)
- Always use
WITH USER_MODEin SOQL queries - Use
Security.stripInaccessible(AccessType.READABLE, records)before returning data - Use
Security.stripInaccessible(AccessType.CREATABLE, records)before insert - Use
Security.stripInaccessible(AccessType.UPDATABLE, records)before update - Always declare classes with
with sharingunless there's an explicit reason not to - NEVER use string concatenation for dynamic SOQL — use bind variables
Bulkification Patterns
- All code must handle 200+ records per transaction (trigger batch size)
- Use
Map<Id, SObject>for efficient lookups - Use
Set<Id>to collect unique IDs before querying related records - Use
Trigger.newMapandTrigger.oldMapfor efficient field change detection
Trigger Pattern
- One trigger per object, maximum
- Trigger contains NO logic — delegates to a handler class
- Handler class implements the logic with proper bulkification
// Trigger
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
AccountTriggerHandler handler = new AccountTriggerHandler();
handler.run();
}
// Handler
public with sharing class AccountTriggerHandler extends TriggerHandler {
public override void beforeInsert() {
// logic here
}
}Naming Conventions
- Classes:
PascalCase(e.g.,AccountService,OpportunityTriggerHandler) - Methods:
camelCase(e.g.,getAccountsByIds,calculateDiscount) - Variables:
camelCase(e.g.,accountList,totalAmount) - Constants:
UPPER_SNAKE_CASE(e.g.,MAX_RETRY_COUNT,DEFAULT_PAGE_SIZE) - Test classes:
ClassNameTest(e.g.,AccountServiceTest)
Code Structure
- Service classes for business logic (
AccountService) - Selector classes for queries (
AccountSelector) - Domain classes for record manipulation (
Accounts) - Trigger handlers for trigger logic (
AccountTriggerHandler)
Async Apex Decision Table
| Feature | @future | Queueable | Batch | Schedulable |
|---|---|---|---|---|
| Callouts | callout=true | Database.AllowsCallouts | Database.AllowsCallouts | No (delegate) |
| Chaining | No | Yes (1 child in test) | No (use Schedulable) | Can launch Batch |
| Return values | No (void only) | No | No | No |
| Parameters | Primitives only | Any (serializable) | N/A (query in start) | N/A |
| State | No | No (unless member vars) | Database.Stateful | No |
| Max records | N/A | N/A | 50M (QueryLocator) | N/A |
| Use when | Simple async, callouts | Complex async, chaining | Large data processing | Recurring/scheduled |
Exception Handling
- Create custom exceptions extending
Exceptionfor domain-specific errors - Parse
Database.SaveResultfor partial DML:Database.insert(records, false) - Always use
try/catcharound callouts — never letCalloutExceptionpropagate unhandled
Invocable Methods (Flow Integration)
public with sharing class AccountActions {
@InvocableMethod(label='Merge Accounts' description='Merges duplicate accounts')
public static List<Result> mergeAccounts(List<Request> requests) {
// Process requests (always bulkified — Flow sends List)
}
public class Request {
@InvocableVariable(required=true) public Id masterId;
@InvocableVariable(required=true) public List<Id> duplicateIds;
}
public class Result {
@InvocableVariable public Boolean success;
@InvocableVariable public String errorMessage;
}
}Custom Metadata vs Custom Settings
- Custom Metadata Types: Deployable, cached, accessed via SOQL or
getInstance(). Use for org-wide configuration. - Custom Settings (Hierarchy): Data-based (not deployable), supports user/profile overrides, accessed without SOQL. Use for user-specific settings.
- CMT counts against SOQL limits when queried; Custom Settings do not.
Dynamic Apex
- Use
JSON.serialize()/JSON.deserialize()for API responses and flexible data structures - Use
Type.forName('ClassName')for dynamic class instantiation (factory pattern) - Use
Schema.getGlobalDescribe()sparingly — it's expensive. Cache results.
Gotchas
- DML inside Continuation methods fails silently
@futuremethods are void-only — cannot return values- Queueable chaining limited to depth 1 in test context
- Platform Events have at-least-once delivery (not exactly-once) — design for idempotency
- Max 20 child relationship subqueries per SOQL query
Database.Statefulin Batch reserializes state between execute() calls — keep state small- Custom Metadata
getInstance()is cached — changes don't reflect until cache clears @futurecannot call another@future— use Queueable for chaining
Review Checklist
When reviewing existing Apex code, check for: 1. SOQL/DML inside loops 2. Missing with sharing 3. Missing CRUD/FLS checks 4. Hardcoded IDs 5. Missing null checks 6. Non-bulkified code 7. Missing error handling for DML operations 8. Debug statements that expose PII 9. String concatenation in dynamic SOQL (injection risk) 10. CPU-intensive operations without limits checks
Workflow
1. Read existing code context using Glob and Read tools 2. Understand the org's object model from metadata if available 3. Generate code following all rules above 4. Include inline comments only where logic is non-obvious 5. Suggest deployment command: sf project deploy start -d force-app/main/default/classes/
References
- Apex Design Patterns — trigger handlers, service layer, selector, batch, queueable, custom exceptions, JSON, dynamic Apex, custom metadata, managed sharing, iterators
- Async Patterns — @future, Queueable, Batch, Schedulable, Continuation, Platform Events, Change Data Capture
- Integration Patterns — REST callouts, Named Credentials, @RestResource, SOAP, WebServiceMock, System.Callable, Composite API
- Governor Limits — per-transaction SOQL, DML, CPU, heap limits
Apex Design Patterns Reference
Trigger Handler Framework
Base Handler
public virtual class TriggerHandler {
@TestVisible
private static Set<String> bypassedHandlers = new Set<String>();
public void run() {
if (bypassedHandlers.contains(getHandlerName())) return;
if (Trigger.isBefore) {
if (Trigger.isInsert) beforeInsert();
if (Trigger.isUpdate) beforeUpdate();
if (Trigger.isDelete) beforeDelete();
} else if (Trigger.isAfter) {
if (Trigger.isInsert) afterInsert();
if (Trigger.isUpdate) afterUpdate();
if (Trigger.isDelete) afterDelete();
if (Trigger.isUndelete) afterUndelete();
}
}
public static void bypass(String handlerName) {
bypassedHandlers.add(handlerName);
}
public static void clearBypass(String handlerName) {
bypassedHandlers.remove(handlerName);
}
private String getHandlerName() {
return String.valueOf(this).split(':')[0];
}
protected virtual void beforeInsert() {}
protected virtual void beforeUpdate() {}
protected virtual void beforeDelete() {}
protected virtual void afterInsert() {}
protected virtual void afterUpdate() {}
protected virtual void afterDelete() {}
protected virtual void afterUndelete() {}
}Handler Implementation
public with sharing class AccountTriggerHandler extends TriggerHandler {
private List<Account> newRecords;
private Map<Id, Account> oldMap;
public AccountTriggerHandler() {
this.newRecords = (List<Account>) Trigger.new;
this.oldMap = (Map<Id, Account>) Trigger.oldMap;
}
protected override void beforeInsert() {
AccountService.setDefaults(newRecords);
}
protected override void afterUpdate() {
List<Account> nameChanged = new List<Account>();
for (Account acc : newRecords) {
if (acc.Name != oldMap.get(acc.Id).Name) {
nameChanged.add(acc);
}
}
if (!nameChanged.isEmpty()) {
AccountService.syncContactAddresses(nameChanged);
}
}
}Service Layer Pattern
public with sharing class AccountService {
public static void setDefaults(List<Account> accounts) {
for (Account acc : accounts) {
if (acc.Industry == null) {
acc.Industry = 'Other';
}
}
}
public static void syncContactAddresses(List<Account> accounts) {
Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
List<Contact> contacts = [
SELECT Id, AccountId, MailingStreet
FROM Contact
WHERE AccountId IN :accountIds
WITH USER_MODE
];
// Update logic...
}
}Selector Pattern
public with sharing class AccountSelector {
public static List<Account> getByIds(Set<Id> ids) {
return [
SELECT Id, Name, Industry, BillingStreet
FROM Account
WHERE Id IN :ids
WITH USER_MODE
];
}
public static List<Account> getByIndustry(String industry) {
return [
SELECT Id, Name, Industry
FROM Account
WHERE Industry = :industry
WITH USER_MODE
LIMIT 200
];
}
public static List<Account> getWithContacts(Set<Id> ids) {
return [
SELECT Id, Name,
(SELECT Id, FirstName, LastName, Email FROM Contacts)
FROM Account
WHERE Id IN :ids
WITH USER_MODE
];
}
}Batch Apex Pattern
public with sharing class AccountCleanupBatch implements
Database.Batchable<SObject>, Database.Stateful {
private Integer processedCount = 0;
private List<String> errors = new List<String>();
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, LastActivityDate
FROM Account
WHERE LastActivityDate < LAST_N_YEARS:2
WITH USER_MODE
]);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
acc.Status__c = 'Inactive';
}
List<Database.SaveResult> results = Database.update(scope, false);
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
processedCount++;
} else {
errors.add(sr.getErrors()[0].getMessage());
}
}
}
public void finish(Database.BatchableContext bc) {
System.debug('Processed: ' + processedCount + ', Errors: ' + errors.size());
}
}Queueable Pattern
public with sharing class AccountProcessingJob implements Queueable, Database.AllowsCallouts {
private List<Id> accountIds;
public AccountProcessingJob(List<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = AccountSelector.getByIds(new Set<Id>(accountIds));
// Process accounts
// Chain another job if needed
if (!remainingIds.isEmpty()) {
System.enqueueJob(new AccountProcessingJob(remainingIds));
}
}
}Platform Event Pattern
// Publisher
public with sharing class OrderEventPublisher {
public static void publishOrderCreated(List<Order> orders) {
List<Order_Event__e> events = new List<Order_Event__e>();
for (Order ord : orders) {
events.add(new Order_Event__e(
Order_Id__c = ord.Id,
Action__c = 'Created'
));
}
EventBus.publish(events);
}
}
// Subscriber (Trigger)
trigger OrderEventTrigger on Order_Event__e (after insert) {
OrderEventHandler.handleEvents(Trigger.new);
}Custom Exception Pattern
public class AccountServiceException extends Exception {
private String errorCode;
private Id recordId;
public AccountServiceException(String message, String errorCode, Id recordId) {
this(message);
this.errorCode = errorCode;
this.recordId = recordId;
}
public String getErrorCode() {
return errorCode;
}
public Id getRecordId() {
return recordId;
}
}
// Usage:
// throw new AccountServiceException(
// 'Account not found in external system',
// 'EXT_NOT_FOUND',
// accountId
// );
// Catching:
// try {
// AccountService.syncExternal(accountId);
// } catch (AccountServiceException e) {
// System.debug('Error ' + e.getErrorCode() + ' for record ' + e.getRecordId());
// System.debug(e.getMessage());
// System.debug(e.getStackTraceString());
// }JSON Serialization/Deserialization
public class JsonPatterns {
// --- Typed Serialization ---
public class AccountDTO {
public String name;
public String industry;
public transient String internalNote; // excluded from serialization
public List<ContactDTO> contacts;
}
public class ContactDTO {
public String firstName;
public String lastName;
public String email;
}
public static String serializeAccounts(List<Account> accounts) {
List<AccountDTO> dtos = new List<AccountDTO>();
for (Account acc : accounts) {
AccountDTO dto = new AccountDTO();
dto.name = acc.Name;
dto.industry = acc.Industry;
dto.internalNote = 'This will not serialize';
dto.contacts = new List<ContactDTO>();
dtos.add(dto);
}
// JSON.serialize() converts Apex objects to JSON strings
return JSON.serialize(dtos);
}
// --- Typed Deserialization ---
public static List<AccountDTO> deserializeAccounts(String jsonString) {
// JSON.deserialize() converts JSON to typed Apex objects
return (List<AccountDTO>) JSON.deserialize(
jsonString, List<AccountDTO>.class
);
}
// --- Untyped Deserialization (for dynamic/unknown JSON) ---
public static void processUntypedJson(String jsonString) {
Map<String, Object> root =
(Map<String, Object>) JSON.deserializeUntyped(jsonString);
String name = (String) root.get('name');
Integer count = (Integer) root.get('count');
List<Object> items = (List<Object>) root.get('items');
for (Object item : items) {
Map<String, Object> itemMap = (Map<String, Object>) item;
System.debug('Item: ' + itemMap.get('label'));
}
}
// --- Pretty Print ---
public static String serializePretty(Object obj) {
return JSON.serializePretty(obj);
}
// --- Suppress Nulls ---
public static String serializeNoNulls(Object obj) {
return JSON.serialize(obj, true); // suppressApexObjectNulls = true
}
}Dynamic Apex
public class DynamicApexPatterns {
// --- Dynamic Class Instantiation ---
public static Object createInstance(String className) {
Type t = Type.forName(className);
if (t == null) {
throw new TypeException('Class not found: ' + className);
}
return t.newInstance();
}
// Example: dynamically instantiate a handler
public static void runHandler(String handlerClassName) {
Type handlerType = Type.forName(handlerClassName);
TriggerHandler handler = (TriggerHandler) handlerType.newInstance();
handler.run();
}
// --- Schema Describe for Runtime Metadata ---
public static Map<String, Schema.SObjectType> getAllObjects() {
return Schema.getGlobalDescribe();
}
public static List<String> getFieldNames(String objectName) {
Schema.SObjectType objType = Schema.getGlobalDescribe().get(objectName);
if (objType == null) {
throw new TypeException('Object not found: ' + objectName);
}
Map<String, Schema.SObjectField> fieldMap =
objType.getDescribe().fields.getMap();
List<String> fieldNames = new List<String>();
for (String fieldName : fieldMap.keySet()) {
fieldNames.add(fieldName);
}
fieldNames.sort();
return fieldNames;
}
public static Schema.DescribeFieldResult getFieldDescribe(
String objectName,
String fieldName
) {
Schema.SObjectType objType = Schema.getGlobalDescribe().get(objectName);
Schema.SObjectField field = objType.getDescribe().fields.getMap().get(fieldName);
return field.getDescribe();
}
// --- Dynamic SOQL ---
public static List<SObject> dynamicQuery(
String objectName,
List<String> fields,
String whereClause,
Integer limitRows
) {
String query = 'SELECT ' + String.join(fields, ', ') +
' FROM ' + objectName;
if (String.isNotBlank(whereClause)) {
query += ' WHERE ' + whereClause;
}
query += ' LIMIT ' + limitRows;
return Database.query(query);
}
}Custom Metadata Retrieval
public class CustomMetadataService {
// --- getInstance() — retrieve a single record by DeveloperName ---
public static Discount_Tier__mdt getDiscountTier(String tierName) {
Discount_Tier__mdt tier = Discount_Tier__mdt.getInstance(tierName);
if (tier == null) {
throw new QueryException('Discount tier not found: ' + tierName);
}
return tier;
}
// --- getAll() — retrieve all records (cached, no SOQL cost) ---
public static Map<String, Discount_Tier__mdt> getAllDiscountTiers() {
return Discount_Tier__mdt.getAll();
}
// --- SOQL-based access (counts toward SOQL limit but supports filtering) ---
public static List<Discount_Tier__mdt> getActiveTiers() {
return [
SELECT DeveloperName, MasterLabel, Discount_Percent__c,
Min_Amount__c, Is_Active__c
FROM Discount_Tier__mdt
WHERE Is_Active__c = true
ORDER BY Min_Amount__c
];
}
// --- Practical usage: config-driven logic ---
public static Decimal getDiscountRate(Decimal amount) {
Map<String, Discount_Tier__mdt> allTiers = Discount_Tier__mdt.getAll();
Decimal bestRate = 0;
for (Discount_Tier__mdt tier : allTiers.values()) {
if (tier.Is_Active__c && amount >= tier.Min_Amount__c) {
if (tier.Discount_Percent__c > bestRate) {
bestRate = tier.Discount_Percent__c;
}
}
}
return bestRate;
}
}
// Caching behavior:
// - getInstance() and getAll() use the metadata cache (no SOQL query consumed).
// - Results are cached for the transaction; changes deployed mid-transaction are NOT reflected.
// - SOQL queries against __mdt DO count toward SOQL limits but always reflect current metadata.Custom Settings (Hierarchy)
public class CustomSettingsService {
// --- Org defaults ---
public static App_Config__c getOrgDefaults() {
App_Config__c config = App_Config__c.getOrgDefaults();
return config;
}
// --- Current user's effective value (hierarchy resolution) ---
public static App_Config__c getCurrentUserConfig() {
// Resolves: User -> Profile -> Org Defaults (most specific wins)
App_Config__c config = App_Config__c.getInstance();
return config;
}
// --- Specific user's value ---
public static App_Config__c getUserConfig(Id userId) {
App_Config__c config = App_Config__c.getInstance(userId);
return config;
}
// --- Specific profile's value ---
public static App_Config__c getProfileConfig(Id profileId) {
App_Config__c config = App_Config__c.getValues(profileId);
return config;
}
// --- Practical usage ---
public static Boolean isFeatureEnabled(String featureName) {
App_Config__c config = App_Config__c.getInstance();
if (config == null) return false;
// Access fields dynamically or use known fields
if (featureName == 'EnableSync') {
return config.Enable_Sync__c;
} else if (featureName == 'EnableNotifications') {
return config.Enable_Notifications__c;
}
return false;
}
// --- Creating/updating org defaults programmatically ---
public static void setOrgDefaults(Boolean enableSync, Integer batchSize) {
App_Config__c config = App_Config__c.getOrgDefaults();
if (config.Id == null) {
config = new App_Config__c(SetupOwnerId = UserInfo.getOrganizationId());
}
config.Enable_Sync__c = enableSync;
config.Batch_Size__c = batchSize;
upsert config;
}
}Apex Managed Sharing
public class ManagedSharingService {
// --- Share an Account with a user ---
public static void shareAccountWithUser(
Id accountId,
Id userId,
String accessLevel
) {
AccountShare share = new AccountShare();
share.AccountId = accountId;
share.UserOrGroupId = userId;
share.AccountAccessLevel = accessLevel; // 'Read', 'Edit', 'All'
share.OpportunityAccessLevel = 'Read'; // Required for AccountShare
share.CaseAccessLevel = 'Read'; // Required for AccountShare
share.RowCause = Schema.AccountShare.RowCause.Manual;
insert share;
}
// --- Share an Opportunity ---
public static void shareOpportunityWithUser(
Id opportunityId,
Id userId,
String accessLevel
) {
OpportunityShare share = new OpportunityShare();
share.OpportunityId = opportunityId;
share.UserOrGroupId = userId;
share.OpportunityAccessLevel = accessLevel; // 'Read' or 'Edit'
share.RowCause = Schema.OpportunityShare.RowCause.Manual;
insert share;
}
// --- Share a custom object with Apex sharing reason ---
public static void shareCustomObject(
Id recordId,
Id userId,
String accessLevel
) {
Project__Share share = new Project__Share();
share.ParentId = recordId;
share.UserOrGroupId = userId;
share.AccessLevel = accessLevel; // 'Read' or 'Edit'
share.RowCause = Schema.Project__Share.RowCause.Team_Member__c;
insert share;
}
// --- Bulk sharing ---
public static void bulkShareRecords(
List<Id> recordIds,
List<Id> userIds,
String accessLevel
) {
List<Project__Share> shares = new List<Project__Share>();
for (Id recordId : recordIds) {
for (Id userId : userIds) {
Project__Share share = new Project__Share();
share.ParentId = recordId;
share.UserOrGroupId = userId;
share.AccessLevel = accessLevel;
share.RowCause = Schema.Project__Share.RowCause.Team_Member__c;
shares.add(share);
}
}
Database.insert(shares, false);
}
// --- Remove sharing ---
public static void removeSharing(Id recordId, Id userId) {
List<Project__Share> shares = [
SELECT Id
FROM Project__Share
WHERE ParentId = :recordId
AND UserOrGroupId = :userId
AND RowCause = :Schema.Project__Share.RowCause.Team_Member__c
];
if (!shares.isEmpty()) {
delete shares;
}
}
}
// Notes:
// - Apex sharing reasons (RowCause) are defined on the custom object under Sharing Reasons.
// - Only available for custom objects; standard objects use Manual row cause.
// - Sharing reasons prevent sharing records from being deleted when the owner changes.
// - The org must have a sharing model of Private or Public Read Only for sharing rules to take effect.Custom Iterator
// --- Iterator interface ---
public class AccountIterator implements Iterator<Account> {
private List<Account> accounts;
private Integer currentIndex;
public AccountIterator(List<Account> accounts) {
this.accounts = accounts;
this.currentIndex = 0;
}
public Boolean hasNext() {
return currentIndex < accounts.size();
}
public Account next() {
if (!hasNext()) {
throw new NoSuchElementException('No more elements');
}
return accounts[currentIndex++];
}
}
// --- Iterable interface (used with Database.Batchable<T>) ---
public class AccountIterable implements Iterable<Account> {
private List<Account> accounts;
public AccountIterable(List<Account> accounts) {
this.accounts = accounts;
}
public Iterator<Account> iterator() {
return new AccountIterator(accounts);
}
}
// --- Using with Batch Apex ---
public class CustomIteratorBatch implements Database.Batchable<Account> {
private List<Account> sourceAccounts;
public CustomIteratorBatch(List<Account> accounts) {
this.sourceAccounts = accounts;
}
public Iterable<Account> start(Database.BatchableContext bc) {
return new AccountIterable(sourceAccounts);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
acc.Description = 'Batch processed';
}
update scope;
}
public void finish(Database.BatchableContext bc) {
System.debug('Custom iterator batch complete.');
}
}
// --- Practical example: chunked iterator for large datasets ---
public class ChunkedIterator implements Iterator<List<SObject>>, Iterable<List<SObject>> {
private List<SObject> records;
private Integer chunkSize;
private Integer currentIndex;
public ChunkedIterator(List<SObject> records, Integer chunkSize) {
this.records = records;
this.chunkSize = chunkSize;
this.currentIndex = 0;
}
public Boolean hasNext() {
return currentIndex < records.size();
}
public List<SObject> next() {
List<SObject> chunk = new List<SObject>();
Integer endIndex = Math.min(currentIndex + chunkSize, records.size());
for (Integer i = currentIndex; i < endIndex; i++) {
chunk.add(records[i]);
}
currentIndex = endIndex;
return chunk;
}
public Iterator<List<SObject>> iterator() {
return this;
}
}Async Apex Patterns Reference
1. @future Method
The simplest async pattern. Runs in a separate transaction with higher governor limits.
Basic @future
public class FutureExample {
@future
public static void processRecordsAsync(Set<Id> recordIds) {
List<Account> accounts = [
SELECT Id, Name, Description
FROM Account
WHERE Id IN :recordIds
WITH USER_MODE
];
for (Account acc : accounts) {
acc.Description = 'Processed at ' + System.now();
}
update accounts;
}
}@future with Callout
public class FutureCalloutExample {
@future(callout=true)
public static void syncToExternalSystem(Set<Id> accountIds) {
List<Account> accounts = [
SELECT Id, Name, BillingCity
FROM Account
WHERE Id IN :accountIds
];
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/accounts');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(accounts));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
System.debug(LoggingLevel.ERROR, 'Callout failed: ' + res.getBody());
}
}
}Limitations
- Must be
static void— cannot return a value. - Parameters must be primitive types or collections of primitives only (no sObjects or complex types).
- Cannot call another @future method from a @future context.
- Cannot be used in Visualforce getMethodName() calls.
- Max 50 @future calls per transaction.
- No job ID returned — cannot monitor execution.
---
2. Queueable Apex
More flexible than @future: supports complex parameters, job chaining, and monitoring.
Basic Queueable
public class AccountEnrichmentJob implements System.Queueable {
private List<Account> accounts;
public AccountEnrichmentJob(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
for (Account acc : accounts) {
acc.Description = 'Enriched by job ' + context.getJobId();
}
Database.update(accounts, false);
}
}
// Enqueue the job:
// Id jobId = System.enqueueJob(new AccountEnrichmentJob(accountList));Queueable with Callouts
public class ExternalSyncJob implements System.Queueable, Database.AllowsCallouts {
private List<Account> accounts;
public ExternalSyncJob(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_Named_Credential/api/sync');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(accounts));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
// Process success
}
}
}Chaining Queueable Jobs
public class ChainedJob implements System.Queueable {
private List<Id> recordIds;
private Integer batchIndex;
private static final Integer CHUNK_SIZE = 200;
public ChainedJob(List<Id> recordIds, Integer batchIndex) {
this.recordIds = recordIds;
this.batchIndex = batchIndex;
}
public void execute(QueueableContext context) {
Integer startIdx = batchIndex * CHUNK_SIZE;
Integer endIdx = Math.min(startIdx + CHUNK_SIZE, recordIds.size());
List<Id> chunk = new List<Id>();
for (Integer i = startIdx; i < endIdx; i++) {
chunk.add(recordIds[i]);
}
// Process chunk...
processChunk(chunk);
// Chain next batch if more records remain
if (endIdx < recordIds.size()) {
System.enqueueJob(new ChainedJob(recordIds, batchIndex + 1));
}
}
private void processChunk(List<Id> chunk) {
// Processing logic
}
}Transaction Finalizers
public class RobustJob implements System.Queueable {
public void execute(QueueableContext context) {
// Attach a finalizer to handle success or failure
System.attachFinalizer(new JobFinalizer());
// Main processing logic
List<Account> accounts = [SELECT Id, Name FROM Account LIMIT 100];
for (Account acc : accounts) {
acc.Description = 'Updated';
}
update accounts;
}
}
public class JobFinalizer implements System.Finalizer {
public void execute(System.FinalizerContext context) {
Id parentJobId = context.getAsyncApexJobId();
System.ParentJobResult result = context.getResult();
if (result == System.ParentJobResult.SUCCESS) {
System.debug('Job ' + parentJobId + ' completed successfully.');
} else if (result == System.ParentJobResult.UNHANDLED_EXCEPTION) {
String errorMessage = context.getException().getMessage();
System.debug(LoggingLevel.ERROR,
'Job ' + parentJobId + ' failed: ' + errorMessage);
// Optionally re-enqueue or log failure
// System.enqueueJob(new RobustJob());
}
}
}Limitations
- Stack depth limit: max 5 chained Queueable jobs in Developer/Trial orgs; no hard limit in Enterprise but subject to async Apex limits.
- Max 50 enqueueJob calls per transaction.
---
3. Schedulable Apex
Run Apex at specific times or intervals using CRON expressions.
Schedulable Implementation
public class DailyAccountCleanup implements System.Schedulable {
public void execute(SchedulableContext sc) {
// Launch a batch job from the scheduler
Database.executeBatch(new AccountCleanupBatch(), 200);
}
}
// Schedule the job:
// String jobId = System.schedule(
// 'Daily Account Cleanup',
// '0 0 2 * * ?', // Every day at 2:00 AM
// new DailyAccountCleanup()
// );CRON Expression Format
Seconds Minutes Hours Day_of_month Month Day_of_week Optional_year
Field Values Special Characters
----- ------ ------------------
Seconds 0-59 , - * /
Minutes 0-59 , - * /
Hours 0-23 , - * /
Day_of_month 1-31 , - * ? / L W
Month 1-12 or JAN-DEC , - * /
Day_of_week 1-7 or SUN-SAT , - * ? / L #
Year (optional) null or 1970-2099 , - * /Common CRON Expressions
'0 0 0 * * ?' — Midnight every day
'0 0 8 * * ?' — 8:00 AM every day
'0 0 */4 * * ?' — Every 4 hours
'0 30 9 ? * MON-FRI' — 9:30 AM weekdays
'0 0 0 1 * ?' — First day of every month at midnight
'0 0 12 ? * 2L' — Last Monday of every month at noonManaging Scheduled Jobs
public class SchedulerManager {
public static String scheduleJob() {
return System.schedule(
'Weekly Report',
'0 0 6 ? * MON',
new DailyAccountCleanup()
);
}
public static void abortJob(String jobName) {
List<CronTrigger> jobs = [
SELECT Id, CronJobDetail.Name, State, NextFireTime
FROM CronTrigger
WHERE CronJobDetail.Name = :jobName
];
for (CronTrigger job : jobs) {
System.abortJob(job.Id);
}
}
public static List<CronTrigger> getScheduledJobs() {
return [
SELECT Id, CronJobDetail.Name, State, NextFireTime,
CronExpression, TimesTriggered
FROM CronTrigger
WHERE CronJobDetail.JobType = '7'
ORDER BY NextFireTime
];
}
}---
4. Batch Apex
Process large data volumes in chunks. Supports up to 50 million records.
Full Batch with QueryLocator
public class LeadDeduplicationBatch implements
Database.Batchable<SObject>,
Database.Stateful,
Database.AllowsCallouts,
Database.RaisesPlatformEvents {
private Integer totalProcessed = 0;
private Integer totalErrors = 0;
private List<String> errorMessages = new List<String>();
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Email, Name, Company, Status, CreatedDate
FROM Lead
WHERE IsConverted = false
AND CreatedDate = LAST_N_DAYS:30
ORDER BY CreatedDate
]);
}
public void execute(Database.BatchableContext bc, List<Lead> scope) {
List<Lead> leadsToUpdate = new List<Lead>();
for (Lead ld : scope) {
ld.Status = 'Reviewed';
leadsToUpdate.add(ld);
}
List<Database.SaveResult> results = Database.update(leadsToUpdate, false);
for (Integer i = 0; i < results.size(); i++) {
if (results[i].isSuccess()) {
totalProcessed++;
} else {
totalErrors++;
for (Database.Error err : results[i].getErrors()) {
errorMessages.add(
'Lead ' + leadsToUpdate[i].Id + ': ' + err.getMessage()
);
}
}
}
}
public void finish(Database.BatchableContext bc) {
AsyncApexJob job = [
SELECT Id, Status, NumberOfErrors,
JobItemsProcessed, TotalJobItems
FROM AsyncApexJob
WHERE Id = :bc.getJobId()
];
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new List<String>{ 'admin@example.com' });
mail.setSubject('Lead Dedup Batch Complete: ' + job.Status);
mail.setPlainTextBody(
'Processed: ' + totalProcessed + '\n' +
'Errors: ' + totalErrors + '\n' +
'Details:\n' + String.join(errorMessages, '\n')
);
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ mail });
}
}
// Execute:
// Id batchId = Database.executeBatch(new LeadDeduplicationBatch(), 200);Batch with Iterable (for non-SOQL data sources)
public class ExternalDataBatch implements Database.Batchable<String> {
private List<String> externalIds;
public ExternalDataBatch(List<String> externalIds) {
this.externalIds = externalIds;
}
public Iterable<String> start(Database.BatchableContext bc) {
return externalIds;
}
public void execute(Database.BatchableContext bc, List<String> scope) {
List<Account> accountsToUpdate = new List<Account>();
for (String extId : scope) {
accountsToUpdate.add(new Account(
External_Id__c = extId,
Last_Synced__c = System.now()
));
}
upsert accountsToUpdate External_Id__c;
}
public void finish(Database.BatchableContext bc) {
System.debug('External data batch complete.');
}
}Error Collection Pattern
public class ErrorCollectingBatch implements
Database.Batchable<SObject>, Database.Stateful {
public class BatchError {
public Id recordId;
public String errorMessage;
public String fieldName;
public BatchError(Id recId, String msg, String field) {
this.recordId = recId;
this.errorMessage = msg;
this.fieldName = field;
}
}
private List<BatchError> allErrors = new List<BatchError>();
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, Email FROM Contact
WHERE Email = null
]);
}
public void execute(Database.BatchableContext bc, List<Contact> scope) {
for (Contact c : scope) {
c.Email = c.Name.replaceAll('\\s+', '.').toLowerCase() + '@placeholder.com';
}
List<Database.SaveResult> results = Database.update(scope, false);
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
for (Database.Error err : results[i].getErrors()) {
allErrors.add(new BatchError(
scope[i].Id,
err.getMessage(),
String.join(err.getFields(), ', ')
));
}
}
}
}
public void finish(Database.BatchableContext bc) {
if (!allErrors.isEmpty()) {
List<Error_Log__c> logs = new List<Error_Log__c>();
for (BatchError be : allErrors) {
logs.add(new Error_Log__c(
Record_Id__c = be.recordId,
Message__c = be.errorMessage,
Field__c = be.fieldName
));
}
insert logs;
}
}
}Batch Size Tuning
- Default batch size: 200.
- For callout-heavy batches: use smaller sizes (e.g., 1-10) since each callout counts.
- For simple field updates: use larger sizes (up to 2000) for throughput.
- QueryLocator can process up to 50 million records; Iterable is limited to 50,000.
- Max 5 active batch jobs simultaneously (100 in Flex Queue).
---
5. Continuation Pattern
For long-running HTTP callouts in Visualforce or Lightning (up to 120 seconds). Does not consume an application server thread while waiting.
Single Continuation Request
public class ContinuationController {
@AuraEnabled(continuation=true cacheable=false)
public static Object startLongRunningCallout() {
Continuation con = new Continuation(120);
con.continuationMethod = 'handleResponse';
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:LongRunningService/api/process');
req.setMethod('GET');
con.addHttpRequest(req);
return con;
}
@AuraEnabled(cacheable=false)
public static Object handleResponse(List<String> labels, Object state) {
HttpResponse response = Continuation.getResponse(labels[0]);
Integer statusCode = response.getStatusCode();
if (statusCode == 200) {
return response.getBody();
} else {
throw new AuraHandledException(
'Callout failed with status ' + statusCode
);
}
}
}Multiple Continuation Requests (up to 3)
public class MultiContinuationController {
@AuraEnabled(continuation=true cacheable=false)
public static Object startParallelCallouts() {
Continuation con = new Continuation(120);
con.continuationMethod = 'handleMultiResponse';
HttpRequest req1 = new HttpRequest();
req1.setEndpoint('callout:ServiceA/api/data');
req1.setMethod('GET');
HttpRequest req2 = new HttpRequest();
req2.setEndpoint('callout:ServiceB/api/data');
req2.setMethod('GET');
HttpRequest req3 = new HttpRequest();
req3.setEndpoint('callout:ServiceC/api/data');
req3.setMethod('GET');
con.addHttpRequest(req1);
con.addHttpRequest(req2);
con.addHttpRequest(req3);
return con;
}
@AuraEnabled(cacheable=false)
public static Object handleMultiResponse(List<String> labels, Object state) {
Map<String, Object> results = new Map<String, Object>();
for (Integer i = 0; i < labels.size(); i++) {
HttpResponse res = Continuation.getResponse(labels[i]);
results.put('service' + (i + 1), res.getBody());
}
return JSON.serialize(results);
}
}Limitations
- No DML operations in continuation callback methods.
- Maximum 3 callouts per continuation.
- Maximum timeout: 120 seconds.
- Supported in Visualforce (VF pages) and Lightning (Aura/LWC).
---
6. Platform Events
Publish-subscribe messaging for event-driven architecture. Supports cross-org and external system integration.
Defining and Publishing Events
public class OrderEventService {
public static void publishOrderEvents(List<Order> orders, String action) {
List<Order_Event__e> events = new List<Order_Event__e>();
for (Order ord : orders) {
events.add(new Order_Event__e(
Order_Id__c = ord.Id,
Action__c = action,
Amount__c = ord.TotalAmount,
Processed_At__c = System.now()
));
}
List<Database.SaveResult> results = EventBus.publish(events);
for (Database.SaveResult sr : results) {
if (!sr.isSuccess()) {
for (Database.Error err : sr.getErrors()) {
System.debug(LoggingLevel.ERROR,
'Event publish error: ' + err.getMessage());
}
}
}
}
}Subscriber Trigger
trigger OrderEventTrigger on Order_Event__e (after insert) {
List<Task> tasks = new List<Task>();
for (Order_Event__e event : Trigger.new) {
if (event.Action__c == 'Created') {
tasks.add(new Task(
Subject = 'Follow up on Order ' + event.Order_Id__c,
WhatId = event.Order_Id__c,
Status = 'Open',
Priority = 'High'
));
}
}
if (!tasks.isEmpty()) {
insert tasks;
}
}Subscriber with Replay ID and Error Handling
trigger OrderEventTrigger on Order_Event__e (after insert) {
for (Order_Event__e event : Trigger.new) {
try {
processEvent(event);
} catch (Exception e) {
// Set replay ID to retry from this event on next trigger invocation
EventBus.TriggerContext.currentContext().setResumeCheckpoint(
event.ReplayId
);
throw e;
}
}
}Key Behaviors
- At-least-once delivery: subscribers may receive the same event more than once; design for idempotency.
- Publish after commit: by default, events publish when the transaction commits. Use
EventBus.publish()for immediate publish behavior. UsePublish After Commitsetting on the event definition. - Replay ID: each event gets a unique replay ID for tracking and resumption.
- Governor limit: 150,000 events published per hour (Standard Platform Events).
- Retention: events are retained for 72 hours (standard) or 24 hours (high-volume).
---
7. Change Data Capture (CDC)
Receive near-real-time notifications when Salesforce records change.
CDC Trigger
trigger AccountChangeEventTrigger on AccountChangeEvent (after insert) {
for (AccountChangeEvent event : Trigger.new) {
EventBus.ChangeEventHeader header = event.ChangeEventHeader;
String changeType = header.getChangeType();
List<String> changedFields = header.getChangedFields();
String commitUser = header.getCommitUser();
String transactionKey = header.getTransactionKey();
Integer sequenceNumber = header.getSequenceNumber();
if (changeType == 'UPDATE') {
handleUpdate(event, changedFields, commitUser);
} else if (changeType == 'CREATE') {
handleCreate(event, commitUser);
} else if (changeType == 'DELETE') {
handleDelete(header.getRecordIds(), commitUser);
} else if (changeType == 'UNDELETE') {
handleUndelete(header.getRecordIds(), commitUser);
}
}
}CDC Handler Methods
public class AccountCDCHandler {
public static void handleUpdate(
AccountChangeEvent event,
List<String> changedFields,
String commitUser
) {
// Only react to changes NOT made by integration user
if (commitUser != getIntegrationUserId()) {
if (changedFields.contains('BillingAddress')) {
// Sync address to external system
syncAddressExternally(event);
}
}
}
public static void handleCreate(AccountChangeEvent event, String commitUser) {
// Provision in external system
System.enqueueJob(new ExternalProvisionJob(event));
}
public static void handleDelete(List<String> recordIds, String commitUser) {
// Archive or clean up in external system
for (String recordId : recordIds) {
System.enqueueJob(new ExternalCleanupJob(recordId));
}
}
public static void handleUndelete(List<String> recordIds, String commitUser) {
// Restore in external system
}
private static Id getIntegrationUserId() {
return [SELECT Id FROM User WHERE Username = 'integration@example.com' LIMIT 1].Id;
}
private static void syncAddressExternally(AccountChangeEvent event) {
// Callout logic
}
}Testing CDC Triggers
@IsTest
private class AccountCDCTest {
@IsTest
static void testAccountChangeEvent() {
// Enable CDC in test context
Test.enableChangeDataCapture();
Account acc = new Account(Name = 'Test CDC Account');
insert acc;
// Deliver the change event
Test.getEventBus().deliver();
// Verify the trigger processed the event
// (assert on side effects like logs, tasks, etc.)
}
@IsTest
static void testAccountUpdateCDC() {
Test.enableChangeDataCapture();
Account acc = new Account(Name = 'Test Account');
insert acc;
Test.getEventBus().deliver();
acc.Name = 'Updated Account';
update acc;
Test.getEventBus().deliver();
// Assert on update side effects
}
}ChangeEventHeader Fields
| Field | Description |
|---|---|
| changeType | CREATE, UPDATE, DELETE, UNDELETE, GAP_CREATE, etc. |
| changedFields | List of field API names that changed |
| commitUser | User ID who made the change |
| commitTimestamp | Timestamp of the commit |
| transactionKey | Unique key for the transaction |
| sequenceNumber | Order of the event within the transaction |
| recordIds | List of record IDs affected |
| changeOrigin | Origin of the change (e.g., com/salesforce/api) |
| entityName | SObject type name |
Apex Integration Patterns Reference
1. REST Callout
Making outbound HTTP requests from Apex to external services.
Basic REST Callout
public class RestCalloutService {
public static String doGet(String endpoint) {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpoint);
req.setMethod('GET');
req.setHeader('Accept', 'application/json');
req.setTimeout(30000); // 30 seconds
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return res.getBody();
} else {
throw new CalloutException(
'GET failed: ' + res.getStatusCode() + ' ' + res.getStatus()
);
}
}
public static String doPost(String endpoint, Object body) {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpoint);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setTimeout(30000);
req.setBody(JSON.serialize(body));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
return res.getBody();
} else {
throw new CalloutException(
'POST failed: ' + res.getStatusCode() + ' ' + res.getBody()
);
}
}
}Parsing JSON Response
public class AccountApiService {
public class AccountData {
public String name;
public String industry;
public String externalId;
}
public static List<AccountData> fetchAccounts(String endpoint) {
String responseBody = RestCalloutService.doGet(endpoint);
// Typed deserialization
List<AccountData> accounts =
(List<AccountData>) JSON.deserialize(
responseBody, List<AccountData>.class
);
return accounts;
}
public static Map<String, Object> fetchUntyped(String endpoint) {
String responseBody = RestCalloutService.doGet(endpoint);
// Untyped deserialization for dynamic JSON
Map<String, Object> result =
(Map<String, Object>) JSON.deserializeUntyped(responseBody);
return result;
}
}Comprehensive Error Handling
public class ResilientCalloutService {
public class CalloutResult {
public Boolean success;
public Integer statusCode;
public String body;
public String errorMessage;
}
public static CalloutResult makeCallout(HttpRequest req) {
CalloutResult result = new CalloutResult();
try {
Http http = new Http();
HttpResponse res = http.send(req);
result.statusCode = res.getStatusCode();
result.body = res.getBody();
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
result.success = true;
} else {
result.success = false;
result.errorMessage = 'HTTP ' + res.getStatusCode() +
': ' + res.getStatus();
}
} catch (CalloutException e) {
result.success = false;
result.errorMessage = 'Callout exception: ' + e.getMessage();
}
return result;
}
}---
2. Named Credential Callout
Secure credential storage and automatic authentication — no hardcoded URLs or secrets in code.
Using Named Credentials
public class NamedCredentialService {
public static String callExternalApi(String path) {
HttpRequest req = new HttpRequest();
// Named Credential handles auth headers automatically
req.setEndpoint('callout:My_External_Service' + path);
req.setMethod('GET');
req.setHeader('Accept', 'application/json');
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return res.getBody();
}
throw new CalloutException('Failed: ' + res.getStatusCode());
}
public static String postData(String path, String jsonBody) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:My_External_Service' + path);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(jsonBody);
Http http = new Http();
HttpResponse res = http.send(req);
return res.getBody();
}
}Benefits Over Hardcoded URLs
- Credentials stored securely in Salesforce metadata, not in code.
- Supports OAuth 2.0, Basic Auth, JWT, AWS Signature, and custom auth.
- Endpoint URL manageable per environment (sandbox vs. production).
- Can merge fields from Named Credential into headers and body.
- Deployable via metadata API / change sets.
---
3. @RestResource — Apex REST Service
Expose custom REST endpoints for external systems to call into Salesforce.
Full REST Resource
@RestResource(urlMapping='/accounts/*')
global with sharing class AccountRestService {
@HttpGet
global static Account getAccount() {
RestRequest req = RestContext.request;
// Extract ID from URL: /services/apexrest/accounts/<Id>
String accountId = req.requestURI.substringAfterLast('/');
return [
SELECT Id, Name, Industry, BillingCity, Phone
FROM Account
WHERE Id = :accountId
WITH USER_MODE
LIMIT 1
];
}
@HttpPost
global static Id createAccount(
String name,
String industry,
String phone
) {
Account acc = new Account(
Name = name,
Industry = industry,
Phone = phone
);
insert acc;
return acc.Id;
}
@HttpPut
global static Account upsertAccount(
String externalId,
String name,
String industry
) {
Account acc = new Account(
External_Id__c = externalId,
Name = name,
Industry = industry
);
upsert acc External_Id__c;
return acc;
}
@HttpPatch
global static Account updateAccount() {
RestRequest req = RestContext.request;
String accountId = req.requestURI.substringAfterLast('/');
Map<String, Object> params =
(Map<String, Object>) JSON.deserializeUntyped(req.requestBody.toString());
Account acc = [SELECT Id FROM Account WHERE Id = :accountId LIMIT 1];
for (String field : params.keySet()) {
acc.put(field, params.get(field));
}
update acc;
return acc;
}
@HttpDelete
global static void deleteAccount() {
RestRequest req = RestContext.request;
String accountId = req.requestURI.substringAfterLast('/');
delete [SELECT Id FROM Account WHERE Id = :accountId LIMIT 1];
}
}Custom Response with RestResponse
@RestResource(urlMapping='/account-search/*')
global with sharing class AccountSearchService {
@HttpGet
global static void searchAccounts() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
String searchTerm = req.params.get('q');
if (String.isBlank(searchTerm)) {
res.statusCode = 400;
res.responseBody = Blob.valueOf(
JSON.serialize(new Map<String, String>{
'error' => 'Missing required parameter: q'
})
);
return;
}
List<Account> results = [
SELECT Id, Name, Industry
FROM Account
WHERE Name LIKE :('%' + searchTerm + '%')
WITH USER_MODE
LIMIT 50
];
res.statusCode = 200;
res.addHeader('Content-Type', 'application/json');
res.responseBody = Blob.valueOf(JSON.serialize(results));
}
}URL Mapping Rules
- Endpoint is accessible at
/services/apexrest/<urlMapping>. - Wildcards:
/accounts/*matches/accounts/001xx000003DGb2. - Only one wildcard
*at the end is supported. - The class must be
global. - Each HTTP method annotation can appear only once per class.
---
4. SOAP Callout
Consuming external SOAP web services using WSDL2Apex-generated classes.
Using WSDL2Apex Generated Code
public class SoapIntegrationService {
public static String getAccountInfo(String accountNumber) {
// Generated classes from WSDL import
externalService.AccountServicePort port = new externalService.AccountServicePort();
// Set endpoint if not using Named Credential
// port.endpoint_x = 'https://api.example.com/soap/AccountService';
// Set timeout
port.timeout_x = 30000;
// Set authentication headers
port.inputHttpHeaders_x = new Map<String, String>{
'Authorization' => 'Basic ' +
EncodingUtil.base64Encode(Blob.valueOf('user:pass'))
};
// Call the SOAP operation
externalService.AccountInfoResponse response =
port.getAccountInfo(accountNumber);
return response.accountName;
}
}WebServiceCallout Pattern
public class SoapCalloutExample {
public class AccountRequest {
public String accountNumber;
}
public class AccountResponse {
public String accountName;
public String status;
}
public static AccountResponse getAccount(String accountNumber) {
AccountRequest request = new AccountRequest();
request.accountNumber = accountNumber;
AccountResponse response = new AccountResponse();
// Direct WebServiceCallout invocation
Map<String, String> ns = new Map<String, String>{
'tns' => 'http://example.com/AccountService'
};
WebServiceCallout.invoke(
null, // stub
response, // response object
new String[]{
'https://api.example.com/soap/AccountService',
'getAccount', // operation name
'http://example.com/AccountService', // namespace
'getAccountRequest', // request element
'http://example.com/AccountService', // response namespace
'getAccountResponse' // response element
},
new Object[]{ request }
);
return response;
}
}---
5. WebServiceMock — Testing SOAP Callouts
Mock Implementation
@IsTest
global class AccountServiceMock implements WebServiceMock {
global void doInvoke(
Object stub,
Object request,
Map<String, Object> response,
String endpoint,
String soapAction,
String requestName,
String responseNS,
String responseName,
String responseType
) {
// Create mock response
externalService.AccountInfoResponse mockResponse =
new externalService.AccountInfoResponse();
mockResponse.accountName = 'Mock Account';
mockResponse.status = 'Active';
// Put the response in the response map
response.put('response_x', mockResponse);
}
}
@IsTest
private class SoapIntegrationServiceTest {
@IsTest
static void testGetAccountInfo() {
Test.setMock(WebServiceMock.class, new AccountServiceMock());
Test.startTest();
String result = SoapIntegrationService.getAccountInfo('ACC-001');
Test.stopTest();
System.assertEquals('Mock Account', result);
}
}---
6. System.Callable Interface
Loosely-coupled Apex integration for managed packages and dynamic method invocation.
Implementing Callable
public class DiscountCalculator implements System.Callable {
public Object call(String action, Map<String, Object> args) {
switch on action.toLowerCase() {
when 'calculatediscount' {
Decimal amount = (Decimal) args.get('amount');
String tier = (String) args.get('tier');
return calculateDiscount(amount, tier);
}
when 'getdiscounttiers' {
return getDiscountTiers();
}
when else {
throw new ExtensionMalformedCallException(
'Unknown action: ' + action
);
}
}
}
private Decimal calculateDiscount(Decimal amount, String tier) {
Map<String, Decimal> rates = new Map<String, Decimal>{
'Bronze' => 0.05,
'Silver' => 0.10,
'Gold' => 0.15,
'Platinum' => 0.20
};
Decimal rate = rates.containsKey(tier) ? rates.get(tier) : 0;
return amount * rate;
}
private List<String> getDiscountTiers() {
return new List<String>{ 'Bronze', 'Silver', 'Gold', 'Platinum' };
}
public class ExtensionMalformedCallException extends Exception {}
}Consuming Callable Dynamically
public class CallableConsumer {
public static Decimal getDiscount(Decimal amount, String tier) {
// Dynamically instantiate — no compile-time dependency
Type calcType = Type.forName('DiscountCalculator');
if (calcType == null) {
throw new TypeException('DiscountCalculator class not found');
}
System.Callable calculator = (System.Callable) calcType.newInstance();
Decimal discount = (Decimal) calculator.call('calculateDiscount',
new Map<String, Object>{
'amount' => amount,
'tier' => tier
}
);
return discount;
}
}Use Cases
- Managed package extensibility: subscriber orgs can invoke logic without namespace knowledge at compile time.
- Plugin architectures: dynamically call different implementations.
- Cross-package communication without direct dependencies.
---
7. Composite API from Apex
Making composite requests to the Salesforce REST API from Apex for multi-step operations in a single call.
Composite Request
public class CompositeApiService {
public class SubRequest {
public String method;
public String url;
public String referenceId;
public Map<String, Object> body;
}
public class CompositeRequest {
public Boolean allOrNone;
public List<SubRequest> compositeRequest;
}
public class SubResponse {
public Integer httpStatusCode;
public Object body;
public String referenceId;
}
public class CompositeResponse {
public List<SubResponse> compositeResponse;
}
public static CompositeResponse executeComposite(
List<SubRequest> subRequests,
Boolean allOrNone
) {
CompositeRequest compReq = new CompositeRequest();
compReq.allOrNone = allOrNone;
compReq.compositeRequest = subRequests;
HttpRequest req = new HttpRequest();
req.setEndpoint(
URL.getOrgDomainUrl().toExternalForm() +
'/services/data/v60.0/composite'
);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('Authorization', 'Bearer ' + UserInfo.getSessionId());
req.setBody(JSON.serialize(compReq));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return (CompositeResponse) JSON.deserialize(
res.getBody(), CompositeResponse.class
);
}
throw new CalloutException('Composite API failed: ' + res.getBody());
}
}Using Composite with Reference IDs
public class CompositeUsageExample {
public static void createAccountWithContact() {
List<CompositeApiService.SubRequest> subRequests =
new List<CompositeApiService.SubRequest>();
// Sub-request 1: Create Account
CompositeApiService.SubRequest createAccount =
new CompositeApiService.SubRequest();
createAccount.method = 'POST';
createAccount.url = '/services/data/v60.0/sobjects/Account';
createAccount.referenceId = 'newAccount';
createAccount.body = new Map<String, Object>{
'Name' => 'Composite Test Account',
'Industry' => 'Technology'
};
subRequests.add(createAccount);
// Sub-request 2: Create Contact referencing the Account
CompositeApiService.SubRequest createContact =
new CompositeApiService.SubRequest();
createContact.method = 'POST';
createContact.url = '/services/data/v60.0/sobjects/Contact';
createContact.referenceId = 'newContact';
createContact.body = new Map<String, Object>{
'FirstName' => 'John',
'LastName' => 'Doe',
'AccountId' => '@{newAccount.id}' // Reference ID from sub-request 1
};
subRequests.add(createContact);
// Sub-request 3: Query the created Account
CompositeApiService.SubRequest queryAccount =
new CompositeApiService.SubRequest();
queryAccount.method = 'GET';
queryAccount.url = '/services/data/v60.0/sobjects/Account/@{newAccount.id}';
queryAccount.referenceId = 'getAccount';
subRequests.add(queryAccount);
CompositeApiService.CompositeResponse response =
CompositeApiService.executeComposite(subRequests, true);
for (CompositeApiService.SubResponse sub : response.compositeResponse) {
System.debug(sub.referenceId + ': HTTP ' + sub.httpStatusCode);
}
}
}Key Points
- Maximum 25 subrequests per composite call.
allOrNone = truerolls back all subrequests if any fail.- Reference IDs let later subrequests use values from earlier ones (e.g.,
@{refId.id}). - Subrequests execute sequentially in order.
- Counts against API request limits (each composite = 1 API call, but each subrequest counts toward governor limits).
Related skills
How it compares
Platform Apex architecture patterns—not generic PHP/Java REST API skills or Salesforce Flow authoring.
FAQ
Who is sf-apex for?
Developers and small teams writing Salesforce Apex who want a proven trigger handler skeleton instead of copying fragmented Stack Overflow snippets.
When should I use sf-apex?
Use it in Build when scaffolding triggers; in Ship when structuring tests with handler bypass; in Operate when iterating handlers without breaking bulk data jobs.
Is sf-apex safe to install?
Check the Security Audits panel on this Prism page; Apex in production orgs still demands your own review of sharing, CRUD/FLS, and packaged IP policies.