The decision that hurts later is ownership
The integration decision that costs you is rarely the protocol. It is ownership. If nobody names which system owns the customer, the invoice, and the order, you end up with two records that both look authoritative, and you find out in a board meeting when two reports disagree.
Pattern follows failure mode, not fashion. Request-reply when a person is waiting for the answer. Batch when the volume is large and the clock is forgiving. Events when the other system must react but must not block yours. Point-to-point is honest when there is genuinely one pipe; MuleSoft earns its invoice when you will reuse the asset. The patterns below include the Apex and the error handling, because an integration nobody can debug at 2 a.m. is not in production. If you want us to choose for your stack, we start by mapping the systems and the fields that must not drift.
Core Integration Patterns
Request-Reply Pattern
The Request-Reply pattern is ideal for real-time, synchronous integrations where immediate response is required.
Implementation Example: REST API Integration
public class ExternalSystemService {
@future(callout=true)
public static void syncAccountToERP(String accountId) {
Account acc = [
SELECT Id, Name, BillingAddress, Industry, AnnualRevenue
FROM Account
WHERE Id = :accountId
];
// Prepare request
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_System/api/customers');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('Authorization', 'Bearer {!$Credential.ERP_System.token}');
// Build request body
Map<String, Object> requestBody = new Map<String, Object>{
'externalId' => acc.Id,
'companyName' => acc.Name,
'industry' => acc.Industry,
'annualRevenue' => acc.AnnualRevenue,
'address' => new Map<String, Object>{
'street' => acc.BillingStreet,
'city' => acc.BillingCity,
'state' => acc.BillingState,
'postalCode' => acc.BillingPostalCode,
'country' => acc.BillingCountry
}
};
req.setBody(JSON.serialize(requestBody));
req.setTimeout(120000); // 2 minute timeout
try {
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
// Parse response
Map<String, Object> responseBody =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
// Update Account with ERP ID
acc.ERP_Customer_ID__c = (String) responseBody.get('customerId');
acc.ERP_Sync_Status__c = 'Synced';
acc.ERP_Last_Sync__c = DateTime.now();
update acc;
// Log success
createIntegrationLog(acc.Id, 'Success', res.getBody());
} else {
// Handle error
createIntegrationLog(acc.Id, 'Error', res.getBody());
throw new IntegrationException(
'ERP sync failed: ' + res.getStatus()
);
}
} catch (Exception e) {
createIntegrationLog(acc.Id, 'Exception', e.getMessage());
throw e;
}
}
}
Fire-and-Forget Pattern
Use this pattern for asynchronous operations where immediate response isn't required.
Platform Events Implementation
// Define Platform Event
// API Name: Account_Change_Event__e
// Fields: Account_Id__c, Change_Type__c, Changed_Fields__c
// Publisher Class
public class AccountEventPublisher {
public static void publishAccountChanges(
List<Account> newAccounts,
Map<Id, Account> oldAccountsMap
) {
List<Account_Change_Event__e> events = new List<Account_Change_Event__e>();
for (Account acc : newAccounts) {
Account oldAcc = oldAccountsMap?.get(acc.Id);
// Detect changes
Set<String> changedFields = new Set<String>();
if (oldAcc != null) {
if (acc.Name != oldAcc.Name) changedFields.add('Name');
if (acc.Industry != oldAcc.Industry) changedFields.add('Industry');
if (acc.AnnualRevenue != oldAcc.AnnualRevenue) changedFields.add('AnnualRevenue');
}
if (!changedFields.isEmpty() || oldAcc == null) {
Account_Change_Event__e event = new Account_Change_Event__e(
Account_Id__c = acc.Id,
Change_Type__c = oldAcc == null ? 'INSERT' : 'UPDATE',
Changed_Fields__c = String.join(new List<String>(changedFields), ',')
);
events.add(event);
}
}
if (!events.isEmpty()) {
List<Database.SaveResult> results = EventBus.publish(events);
// Check publishing results
for (Database.SaveResult sr : results) {
if (!sr.isSuccess()) {
// Log publishing errors
System.debug('Event publishing failed: ' + sr.getErrors()[0].getMessage());
}
}
}
}
}
// Subscriber (External System)
// Subscribe to: /event/Account_Change_Event__e
// Example Node.js subscriber using jsforce
javascript
const jsforce = require('jsforce');
const conn = new jsforce.Connection({
loginUrl: 'https://login.salesforce.com',
version: '57.0'
});
conn.login(username, password, (err, userInfo) => {
if (err) return console.error(err);
// Subscribe to platform event
const subscription = conn.streaming.topic("/event/Account_Change_Event__e")
.subscribe((message) => {
// account change received
// Process the change
processAccountChange({
accountId: message.payload.Account_Id__c,
changeType: message.payload.Change_Type__c,
changedFields: message.payload.Changed_Fields__c
});
});
subscription.on('error', (error) => {
console.error('Subscription error:', error);
});
});
```
Batch Data Synchronization
Batch Integration Patterns
For large-scale data synchronization, batch processing provides optimal performance and resource utilization.
Bulk API 2.0 Implementation
// Batch Apex for Data Export
public class AccountDataExportBatch implements Database.Batchable<sObject>,
Database.Stateful, Database.AllowsCallouts {
private String query;
private String targetSystem;
private List<String> errors = new List<String>();
public AccountDataExportBatch(String query, String targetSystem) {
this.query = query;
this.targetSystem = targetSystem;
}
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(query);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
// Prepare bulk data
List<Map<String, Object>> bulkData = new List<Map<String, Object>>();
for (Account acc : scope) {
Map<String, Object> record = new Map<String, Object>{
'salesforce_id' => acc.Id,
'name' => acc.Name,
'industry' => acc.Industry,
'annual_revenue' => acc.AnnualRevenue,
'number_of_employees' => acc.NumberOfEmployees,
'website' => acc.Website,
'phone' => acc.Phone,
'billing_address' => new Map<String, Object>{
'street' => acc.BillingStreet,
'city' => acc.BillingCity,
'state' => acc.BillingState,
'postal_code' => acc.BillingPostalCode,
'country' => acc.BillingCountry
},
'last_modified_date' => acc.LastModifiedDate
};
bulkData.add(record);
}
// Send to external system
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:' + targetSystem + '/api/bulk/accounts');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(bulkData));
req.setTimeout(120000);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
errors.add('Batch failed: ' + res.getBody());
}
} catch (Exception e) {
errors.add('Exception in batch: ' + e.getMessage());
}
}
public void finish(Database.BatchableContext bc) {
// Send summary email
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[] { UserInfo.getUserEmail() });
mail.setSubject('Account Data Export Completed');
String body = 'Export to ' + targetSystem + ' completed.\n\n';
if (!errors.isEmpty()) {
body += 'Errors encountered:\n' + String.join(errors, '\n');
} else {
body += 'All batches processed successfully.';
}
mail.setPlainTextBody(body);
Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
}
}
// Schedule the batch job
AccountDataExportBatch batch = new AccountDataExportBatch(
'SELECT Id, Name, Industry, AnnualRevenue, NumberOfEmployees, ' +
'Website, Phone, BillingStreet, BillingCity, BillingState, ' +
'BillingPostalCode, BillingCountry, LastModifiedDate ' +
'FROM Account WHERE LastModifiedDate >= LAST_N_DAYS:1',
'ERP_System'
);
Database.executeBatch(batch, 200);
Change Data Capture (CDC) Pattern
// Enable CDC for Account object in Setup
// Subscribe to change events
// CDC Event Handler
public class AccountCDCHandler {
public static void handleChanges(List<AccountChangeEvent> changes) {
List<Integration_Queue__c> queueItems = new List<Integration_Queue__c>();
for (AccountChangeEvent event : changes) {
// Get change header
EventBus.ChangeEventHeader header = event.ChangeEventHeader;
// Process based on change type
String changeType = header.changeType;
List<String> recordIds = header.recordIds;
for (String recordId : recordIds) {
Integration_Queue__c queueItem = new Integration_Queue__c(
Record_Id__c = recordId,
Object_Type__c = 'Account',
Operation__c = changeType,
Status__c = 'Pending',
Changed_Fields__c = String.join(header.changedFields, ','),
Retry_Count__c = 0
);
queueItems.add(queueItem);
}
}
if (!queueItems.isEmpty()) {
insert queueItems;
// Trigger queue processor
System.enqueueJob(new IntegrationQueueProcessor(queueItems[0].Id));
}
}
}API Design and Governance
RESTful API Design
Custom REST API Implementation
@RestResource(urlMapping='/api/v1/accounts/*')
global with sharing class AccountRestService {
@HttpGet
global static AccountWrapper getAccount() {
RestRequest req = RestContext.request;
String accountId = req.requestURI.substringAfterLast('/');
try {
Account acc = [
SELECT Id, Name, Industry, AnnualRevenue, Website,
(SELECT Id, Name, Email, Title FROM Contacts LIMIT 5)
FROM Account
WHERE Id = :accountId
];
return new AccountWrapper(acc);
} catch (Exception e) {
RestResponse res = RestContext.response;
res.statusCode = 404;
res.responseBody = Blob.valueOf(JSON.serialize(
new ErrorResponse('Account not found', e.getMessage())
));
return null;
}
}
@HttpPost
global static ResponseWrapper createAccount(AccountWrapper accountData) {
try {
// Validate input
ValidationResult validation = validateAccountData(accountData);
if (!validation.isValid) {
RestResponse res = RestContext.response;
res.statusCode = 400;
return new ResponseWrapper(false, validation.errors);
}
// Create account
Account acc = new Account(
Name = accountData.name,
Industry = accountData.industry,
AnnualRevenue = accountData.annualRevenue,
Website = accountData.website
);
insert acc;
// Create contacts if provided
if (accountData.contacts != null && !accountData.contacts.isEmpty()) {
List<Contact> contacts = new List<Contact>();
for (ContactWrapper cw : accountData.contacts) {
contacts.add(new Contact(
AccountId = acc.Id,
FirstName = cw.firstName,
LastName = cw.lastName,
Email = cw.email,
Title = cw.title
));
}
insert contacts;
}
RestResponse res = RestContext.response;
res.statusCode = 201;
res.addHeader('Location', '/api/v1/accounts/' + acc.Id);
return new ResponseWrapper(true, 'Account created successfully', acc.Id);
} catch (Exception e) {
RestResponse res = RestContext.response;
res.statusCode = 500;
return new ResponseWrapper(false, e.getMessage());
}
}
@HttpPatch
global static ResponseWrapper updateAccount(Map<String, Object> fields) {
RestRequest req = RestContext.request;
String accountId = req.requestURI.substringAfterLast('/');
try {
Account acc = [SELECT Id FROM Account WHERE Id = :accountId];
// Dynamic field update
for (String fieldName : fields.keySet()) {
// Validate field exists and is updateable
Schema.SObjectField field = Schema.Account.sObjectType
.getDescribe().fields.getMap().get(fieldName);
if (field != null && field.getDescribe().isUpdateable()) {
acc.put(fieldName, fields.get(fieldName));
} else {
throw new ApiException('Invalid or non-updateable field: ' + fieldName);
}
}
update acc;
return new ResponseWrapper(true, 'Account updated successfully');
} catch (Exception e) {
RestResponse res = RestContext.response;
res.statusCode = 400;
return new ResponseWrapper(false, e.getMessage());
}
}
// Wrapper classes
global class AccountWrapper {
public String id;
public String name;
public String industry;
public Decimal annualRevenue;
public String website;
public List<ContactWrapper> contacts;
public AccountWrapper(Account acc) {
this.id = acc.Id;
this.name = acc.Name;
this.industry = acc.Industry;
this.annualRevenue = acc.AnnualRevenue;
this.website = acc.Website;
this.contacts = new List<ContactWrapper>();
for (Contact c : acc.Contacts) {
this.contacts.add(new ContactWrapper(c));
}
}
}
global class ContactWrapper {
public String id;
public String firstName;
public String lastName;
public String email;
public String title;
public ContactWrapper(Contact c) {
this.id = c.Id;
this.firstName = c.FirstName;
this.lastName = c.LastName;
this.email = c.Email;
this.title = c.Title;
}
}
}
API Versioning Strategy
// Version 2 of the API with backward compatibility
@RestResource(urlMapping='/api/v2/accounts/*')
global with sharing class AccountRestServiceV2 {
@HttpGet
global static AccountWrapperV2 getAccount() {
// Include additional fields and relationships
RestRequest req = RestContext.request;
String accountId = req.requestURI.substringAfterLast('/');
// Check for API version header
String apiVersion = req.headers.get('API-Version');
if (apiVersion == '1.0') {
// Return V1 format for backward compatibility
return convertToV1Format(getAccountData(accountId));
}
// Return V2 format by default
return getAccountData(accountId);
}
}Security and Authentication Patterns
OAuth 2.0 Implementation
Connected App Configuration
<!-- Connected App Settings -->
<connectedApp>
<contactEmail>admin@company.com</contactEmail>
<label>External System Integration</label>
<oauthConfig>
<callbackUrl>https://external-system.com/oauth/callback</callbackUrl>
<scopes>
<scope>api</scope>
<scope>refresh_token</scope>
<scope>offline_access</scope>
</scopes>
<isSecretRequired>true</isSecretRequired>
</oauthConfig>
<oauthPolicy>
<ipRelaxation>ENFORCE</ipRelaxation>
<refreshTokenPolicy>infinite</refreshTokenPolicy>
</oauthPolicy>
</connectedApp>
JWT Bearer Flow Implementation
public class JWTBearerFlow {
public static String getAccessToken() {
// JWT Header
Map<String, Object> header = new Map<String, Object>{
'alg' => 'RS256',
'typ' => 'JWT'
};
// JWT Claims
Map<String, Object> claims = new Map<String, Object>{
'iss' => '3MVG9...', // Consumer Key
'sub' => 'integration@company.com', // Username
'aud' => 'https://login.salesforce.com',
'exp' => DateTime.now().addMinutes(5).getTime() / 1000
};
// Generate JWT
String jwt = generateJWT(header, claims);
// Request access token
HttpRequest req = new HttpRequest();
req.setEndpoint('https://login.salesforce.com/services/oauth2/token');
req.setMethod('POST');
req.setBody('grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' +
'&assertion=' + jwt);
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> response =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
return (String) response.get('access_token');
} else {
throw new AuthException('JWT authentication failed: ' + res.getBody());
}
}
private static String generateJWT(
Map<String, Object> header,
Map<String, Object> claims
) {
// Implementation details for JWT generation
// Typically involves signing with private key
return 'generated.jwt.token';
}
}
API Security Best Practices
@RestResource(urlMapping='/api/secure/data/*')
global with sharing class SecureDataService {
@HttpPost
global static ResponseWrapper processData(RequestWrapper request) {
try {
// 1. Validate authentication
if (!validateApiKey()) {
return new ResponseWrapper(401, 'Unauthorized');
}
// 2. Check rate limits
if (!checkRateLimit()) {
return new ResponseWrapper(429, 'Rate limit exceeded');
}
// 3. Validate input
ValidationResult validation = validateInput(request);
if (!validation.isValid) {
return new ResponseWrapper(400, validation.errors);
}
// 4. Check permissions
if (!hasPermission(request.operation)) {
return new ResponseWrapper(403, 'Forbidden');
}
// 5. Process request with encryption
String encryptedData = Crypto.encryptWithManagedIV(
'AES256',
Blob.valueOf(getEncryptionKey()),
Blob.valueOf(JSON.serialize(request.data))
).toString();
// 6. Audit trail
createAuditLog(request, 'Success');
return new ResponseWrapper(200, 'Success', encryptedData);
} catch (Exception e) {
createAuditLog(request, 'Error: ' + e.getMessage());
return new ResponseWrapper(500, 'Internal server error');
}
}
private static Boolean validateApiKey() {
String apiKey = RestContext.request.headers.get('X-API-Key');
// Validate against Custom Metadata Type
API_Key__mdt keyRecord = [
SELECT Id, Is_Active__c, Expiration_Date__c
FROM API_Key__mdt
WHERE Key__c = :apiKey
AND Is_Active__c = true
LIMIT 1
];
return keyRecord != null &&
(keyRecord.Expiration_Date__c == null ||
keyRecord.Expiration_Date__c > Date.today());
}
private static Boolean checkRateLimit() {
String clientId = RestContext.request.headers.get('X-Client-Id');
// Check requests in last hour
Integer requestCount = [
SELECT COUNT()
FROM API_Request_Log__c
WHERE Client_Id__c = :clientId
AND CreatedDate = LAST_N_HOURS:1
];
return requestCount < 1000; // 1000 requests per hour limit
}
}Error Handling and Monitoring
Error handling someone can read at 2 a.m.
Retry Pattern Implementation
public class IntegrationRetryHandler implements Queueable, Database.AllowsCallouts {
private Integration_Queue__c queueItem;
private static final Integer MAX_RETRIES = 3;
private static final List<Integer> RETRY_INTERVALS = new List<Integer>{
1, 5, 15 // Minutes
};
public IntegrationRetryHandler(Id queueItemId) {
this.queueItem = [
SELECT Id, Record_Id__c, Object_Type__c, Operation__c,
Payload__c, Retry_Count__c, Status__c
FROM Integration_Queue__c
WHERE Id = :queueItemId
];
}
public void execute(QueueableContext context) {
try {
// Attempt integration
IntegrationResult result = performIntegration();
if (result.isSuccess) {
queueItem.Status__c = 'Completed';
queueItem.Completed_Date__c = DateTime.now();
} else {
handleFailure(result);
}
update queueItem;
} catch (Exception e) {
handleException(e);
}
}
private void handleFailure(IntegrationResult result) {
queueItem.Last_Error__c = result.errorMessage;
queueItem.Retry_Count__c = queueItem.Retry_Count__c + 1;
if (queueItem.Retry_Count__c < MAX_RETRIES) {
// Schedule retry
Integer delayMinutes = RETRY_INTERVALS[Integer.valueOf(queueItem.Retry_Count__c) - 1];
queueItem.Status__c = 'Retry Scheduled';
queueItem.Next_Retry__c = DateTime.now().addMinutes(delayMinutes);
// Enqueue with delay
System.enqueueJob(
new IntegrationRetryHandler(queueItem.Id),
delayMinutes
);
} else {
// Max retries reached
queueItem.Status__c = 'Failed';
notifyAdministrators();
}
}
private void handleException(Exception e) {
queueItem.Status__c = 'Error';
queueItem.Last_Error__c = e.getTypeName() + ': ' + e.getMessage();
queueItem.Stack_Trace__c = e.getStackTraceString();
update queueItem;
// Create platform event for real-time monitoring
Integration_Error_Event__e errorEvent = new Integration_Error_Event__e(
Queue_Item_Id__c = queueItem.Id,
Error_Message__c = e.getMessage(),
Stack_Trace__c = e.getStackTraceString(),
Severity__c = 'High'
);
EventBus.publish(errorEvent);
}
}
Integration Monitoring Dashboard
public class IntegrationMonitoringService {
@AuraEnabled(cacheable=true)
public static IntegrationMetrics getIntegrationMetrics() {
IntegrationMetrics metrics = new IntegrationMetrics();
// Last 24 hours statistics
DateTime last24Hours = DateTime.now().addHours(-24);
// Success rate
AggregateResult[] results = [
SELECT Status__c, COUNT(Id) cnt
FROM Integration_Queue__c
WHERE CreatedDate >= :last24Hours
GROUP BY Status__c
];
for (AggregateResult ar : results) {
String status = (String) ar.get('Status__c');
Integer count = (Integer) ar.get('cnt');
if (status == 'Completed') {
metrics.successCount = count;
} else if (status == 'Failed') {
metrics.failureCount = count;
} else if (status == 'Retry Scheduled' || status == 'Pending') {
metrics.pendingCount = count;
}
}
// Average response time
AggregateResult avgResult = [
SELECT AVG(Response_Time__c) avgTime
FROM Integration_Log__c
WHERE CreatedDate >= :last24Hours
AND Status__c = 'Success'
];
metrics.averageResponseTime = avgResult.get('avgTime') != null ?
(Decimal) avgResult.get('avgTime') : 0;
// Error trends
metrics.errorTrends = getErrorTrends();
return metrics;
}
public class IntegrationMetrics {
@AuraEnabled public Integer successCount { get; set; }
@AuraEnabled public Integer failureCount { get; set; }
@AuraEnabled public Integer pendingCount { get; set; }
@AuraEnabled public Decimal averageResponseTime { get; set; }
@AuraEnabled public List<ErrorTrend> errorTrends { get; set; }
public IntegrationMetrics() {
this.successCount = 0;
this.failureCount = 0;
this.pendingCount = 0;
this.averageResponseTime = 0;
this.errorTrends = new List<ErrorTrend>();
}
}
}