Callout Best Practices

Callout Limitations

Understanding Salesforce callout limitations is critical for designing robust integrations:

Synchronous Callout Limitations

Asynchronous Callout Limitations

Impact on Design

These limitations shape callout architecture:

Use Named Credentials for Authentication

Named Credentials are the gold standard for managing external system authentication in Salesforce.

Benefits of Named Credentials

Implementation Pattern

// Good: Use Named Credential
req.setEndpoint('callout:MyNamedCredential/api/endpoint');

// Bad: Hardcoded URL
req.setEndpoint('https://api.example.com/endpoint');

Named Credential Configuration

Related Patterns:

Implement Proper Error Handling

Robust error handling is essential for production callouts. Always plan for various failure scenarios.

Error Handling Patterns

Pattern 1: Status Code Checking

HttpResponse response = http.send(req);

if (response.getStatusCode() >= 200 && response.getStatusCode() < 300) {
    // Success - process response
    return parseResponse(response);
} else if (response.getStatusCode() >= 400 && response.getStatusCode() < 500) {
    // Client error - don't retry
    throw new IntegrationException('Client error: ' + response.getStatusCode());
} else if (response.getStatusCode() >= 500) {
    // Server error - retryable
    throw new RetryableIntegrationException('Server error: ' + response.getStatusCode());
}

Pattern 2: Exception Handling

try {
    HttpResponse response = http.send(req);
    return processResponse(response);
} catch (CalloutException e) {
    // Network/connection errors - retryable
    LOG_LogMessageUtility.logError('IntegrationService', 'makeCallout', 
        'Callout exception: ' + e.getMessage(), e);
    throw new RetryableIntegrationException('Callout failed: ' + e.getMessage(), e);
} catch (Exception e) {
    // Unexpected errors
    LOG_LogMessageUtility.logError('IntegrationService', 'makeCallout', 
        'Unexpected error: ' + e.getMessage(), e);
    throw new IntegrationException('Unexpected error: ' + e.getMessage(), e);
}

Pattern 3: Retryable vs Non-Retryable Errors

private static Boolean isRetryableError(Integer statusCode, Exception e) {
    // Retry on server errors (5xx) and timeouts
    if (statusCode != null && statusCode >= 500) {
        return true;
    }
    
    // Retry on connection/timeout errors
    String message = e.getMessage();
    return message.contains('timeout') || 
           message.contains('Connection') ||
           message.contains('Read timed out');
}

Error Handling Best Practices

Related Patterns: Error Handling and Logging

Leverage Asynchronous Patterns

For non-critical callouts or when dealing with multiple external systems, use asynchronous patterns to improve user experience and avoid governor limits.

When to Use Asynchronous Callouts

Pattern 1: Queueable for Callouts After DML

public class CalloutQueueable implements Queueable, Database.AllowsCallouts {
    
    private Id recordId;
    private String endpoint;
    private Map<String, Object> payload;
    
    public CalloutQueueable(Id recordId, String endpoint, Map<String, Object> payload) {
        this.recordId = recordId;
        this.endpoint = endpoint;
        this.payload = payload;
    }
    
    public void execute(QueueableContext context) {
        // Make callout in separate transaction
        HttpResponse response = RestIntegrationService.post(endpoint, payload);
        
        // Update record with response
        // Note: This is a new transaction, so DML is allowed
        updateRecordWithResponse(recordId, response);
    }
}

// Usage: After DML, enqueue callout
insert contact;
System.enqueueJob(new CalloutQueueable(contact.Id, '/api/sync', payload));

Pattern 2: Queueable for Multiple Callouts

public class MultiCalloutQueueable implements Queueable, Database.AllowsCallouts {
    
    private List<CalloutRequest> requests;
    
    public void execute(QueueableContext context) {
        for (CalloutRequest req : requests) {
            try {
                HttpResponse response = RestIntegrationService.makeCallout(
                    req.endpoint, req.method, req.payload
                );
                req.handleResponse(response);
            } catch (Exception e) {
                req.handleError(e);
            }
        }
    }
}

Pattern 3: @future for Simple Async Callouts

@future(callout=true)
public static void makeAsyncCallout(String endpoint, String payloadJson) {
    Map<String, Object> payload = (Map<String, Object>)JSON.deserializeUntyped(payloadJson);
    RestIntegrationService.post(endpoint, payload);
}

Note: Prefer Queueable over @future for new development due to better error handling and chaining capabilities.

Related Patterns: Asynchronous Apex Patterns, Queueable Examples

Implement Circuit Breaker Pattern

For high-volume integrations, implement a circuit breaker pattern to prevent cascading failures and protect external systems from overload.

Circuit Breaker States

Implementation Pattern

public class CircuitBreaker {
    
    private static final Integer FAILURE_THRESHOLD = 5;
    private static final Integer TIMEOUT_SECONDS = 60;
    
    private static Map<String, CircuitState> circuitStates = new Map<String, CircuitState>();
    
    public class CircuitState {
        public Integer failureCount = 0;
        public Datetime lastFailureTime;
        public Boolean isOpen = false;
    }
    
    public static Boolean isCircuitOpen(String circuitName) {
        CircuitState state = getCircuitState(circuitName);
        
        if (!state.isOpen) {
            return false;
        }
        
        // Check if timeout has elapsed (half-open state)
        if (state.lastFailureTime != null && 
            Datetime.now().getTime() - state.lastFailureTime.getTime() > TIMEOUT_SECONDS * 1000) {
            state.isOpen = false; // Move to half-open
            return false;
        }
        
        return true;
    }
    
    public static void recordSuccess(String circuitName) {
        CircuitState state = getCircuitState(circuitName);
        state.failureCount = 0;
        state.isOpen = false;
    }
    
    public static void recordFailure(String circuitName) {
        CircuitState state = getCircuitState(circuitName);
        state.failureCount++;
        state.lastFailureTime = Datetime.now();
        
        if (state.failureCount >= FAILURE_THRESHOLD) {
            state.isOpen = true;
            LOG_LogMessageUtility.logError('CircuitBreaker', 'recordFailure', 
                'Circuit opened for: ' + circuitName);
        }
    }
    
    private static CircuitState getCircuitState(String circuitName) {
        if (!circuitStates.containsKey(circuitName)) {
            circuitStates.put(circuitName, new CircuitState());
        }
        return circuitStates.get(circuitName);
    }
}

// Usage in integration service
public static HttpResponse makeCalloutWithCircuitBreaker(String endpoint, String method, Map<String, Object> payload) {
    String circuitName = 'ExternalAPI';
    
    if (CircuitBreaker.isCircuitOpen(circuitName)) {
        throw new IntegrationException('Circuit breaker is open. External system unavailable.');
    }
    
    try {
        HttpResponse response = RestIntegrationService.makeCallout(endpoint, method, payload);
        CircuitBreaker.recordSuccess(circuitName);
        return response;
    } catch (Exception e) {
        CircuitBreaker.recordFailure(circuitName);
        throw e;
    }
}

Circuit Breaker Best Practices

Use Queueable Apex for Complex Callout Chains

When you need to make multiple related callouts or handle complex processing, Queueable Apex provides more flexibility than @future methods.

Pattern 1: Chained Callouts

public class ChainedCalloutQueueable implements Queueable, Database.AllowsCallouts {
    
    private List<CalloutStep> steps;
    private Integer currentStep = 0;
    
    public void execute(QueueableContext context) {
        if (currentStep >= steps.size()) {
            return; // All steps complete
        }
        
        CalloutStep step = steps[currentStep];
        HttpResponse response = RestIntegrationService.makeCallout(
            step.endpoint, step.method, step.payload
        );
        
        // Process response and prepare next step
        step.processResponse(response);
        currentStep++;
        
        // Chain next step if more steps remain
        if (currentStep < steps.size()) {
            System.enqueueJob(this);
        }
    }
}

Pattern 2: Callout with Post-Processing

public class CalloutWithProcessingQueueable implements Queueable, Database.AllowsCallouts {
    
    private Id recordId;
    private String endpoint;
    
    public void execute(QueueableContext context) {
        // Make callout
        HttpResponse response = RestIntegrationService.get(endpoint);
        
        // Process response and update records
        Map<String, Object> data = parseResponse(response);
        updateRelatedRecords(recordId, data);
        
        // Trigger downstream processing if needed
        if (needsDownstreamProcessing(data)) {
            System.enqueueJob(new DownstreamProcessingQueueable(recordId, data));
        }
    }
}

Related Patterns: Asynchronous Apex Patterns

Optimize Response Processing

When dealing with large responses, optimize memory usage and processing to avoid heap size limits.

Pattern 1: Stream Processing for Large Responses

public static void processLargeResponse(HttpResponse response) {
    // For very large responses, process in chunks
    String body = response.getBody();
    
    // Use JSON parser with streaming for large payloads
    JSONParser parser = JSON.createParser(body);
    
    while (parser.nextToken() != null) {
        if (parser.getCurrentToken() == JSONToken.START_OBJECT) {
            Map<String, Object> record = (Map<String, Object>)parser.readValueAs(Map.class);
            processRecord(record);
            
            // Clear processed data to free memory
            record.clear();
        }
    }
}

Pattern 2: Selective Field Processing

public static List<Map<String, Object>> extractRelevantFields(String responseBody) {
    Map<String, Object> fullResponse = (Map<String, Object>)JSON.deserializeUntyped(responseBody);
    
    // Extract only needed fields to reduce memory usage
    List<String> relevantFields = new List<String>{'id', 'name', 'status'};
    List<Map<String, Object>> extracted = new List<Map<String, Object>>();
    
    for (Object recordObj : (List<Object>)fullResponse.get('records')) {
        Map<String, Object> record = (Map<String, Object>)recordObj;
        Map<String, Object> extractedRecord = new Map<String, Object>();
        
        for (String field : relevantFields) {
            if (record.containsKey(field)) {
                extractedRecord.put(field, record.get(field));
            }
        }
        
        extracted.add(extractedRecord);
    }
    
    return extracted;
}

Pattern 3: Batch Processing Large Responses

public static void processResponseInBatches(HttpResponse response) {
    List<Map<String, Object>> allRecords = parseResponse(response);
    
    // Process in batches to avoid heap size limits
    Integer batchSize = 100;
    for (Integer i = 0; i < allRecords.size(); i += batchSize) {
        Integer endIndex = Math.min(i + batchSize, allRecords.size());
        List<Map<String, Object>> batch = new List<Map<String, Object>>();
        
        for (Integer j = i; j < endIndex; j++) {
            batch.add(allRecords[j]);
        }
        
        processBatch(batch);
        batch.clear(); // Free memory
    }
}

Response Processing Best Practices

Related Patterns: Governor Limits and Optimization

Monitor and Log Callout Performance

Implement comprehensive monitoring and logging to track callout performance and troubleshoot issues.

Pattern 1: Performance Logging

public static HttpResponse makeCalloutWithLogging(String endpoint, String method, Map<String, Object> payload) {
    Long startTime = System.now().getTime();
    String requestId = generateRequestId();
    
    try {
        HttpResponse response = RestIntegrationService.makeCallout(endpoint, method, payload);
        
        Long duration = System.now().getTime() - startTime;
        
        // Log performance metrics
        LOG_LogMessageUtility.logInfo('IntegrationService', 'makeCallout', 
            'Callout completed: ' + method + ' ' + endpoint + 
            ' - Status: ' + response.getStatusCode() + 
            ' - Duration: ' + duration + 'ms' +
            ' - RequestId: ' + requestId);
        
        return response;
        
    } catch (Exception e) {
        Long duration = System.now().getTime() - startTime;
        
        LOG_LogMessageUtility.logError('IntegrationService', 'makeCallout', 
            'Callout failed: ' + method + ' ' + endpoint + 
            ' - Duration: ' + duration + 'ms' +
            ' - RequestId: ' + requestId, e);
        
        throw e;
    }
}

Pattern 2: Callout Metrics Tracking

public class CalloutMetrics {
    
    public static void trackCallout(String integrationName, String endpoint, Integer statusCode, Long duration) {
        // Store metrics in custom object or Platform Event
        Callout_Metric__c metric = new Callout_Metric__c(
            Integration_Name__c = integrationName,
            Endpoint__c = endpoint,
            Status_Code__c = statusCode,
            Duration_ms__c = duration,
            Timestamp__c = Datetime.now()
        );
        
        insert metric;
    }
}

Pattern 3: Alerting on Failures

public static void checkCalloutHealth(String integrationName) {
    // Query recent failures
    Integer failureCount = [
        SELECT COUNT() 
        FROM Callout_Metric__c 
        WHERE Integration_Name__c = :integrationName
        AND Status_Code__c >= 500
        AND Timestamp__c >= :Datetime.now().addHours(-1)
    ];
    
    if (failureCount > 10) {
        // Send alert via Platform Event or notification
        sendAlert('High failure rate detected for: ' + integrationName);
    }
}

Monitoring Best Practices

Related Patterns: Monitoring and Alerting

Avoid DML Before Callout

Salesforce does not allow DML operations before callouts in the same transaction. Use asynchronous patterns to separate DML and callouts.

The Restriction

// This will FAIL
insert contact;
HttpResponse response = RestIntegrationService.post('/api/sync', payload);
// Error: You have uncommitted work pending. Please commit or rollback before calling out

Solution 1: Use Queueable

// DML in synchronous context
insert contact;

// Enqueue callout in separate transaction
System.enqueueJob(new CalloutQueueable(contact.Id, '/api/sync', payload));

Solution 2: Use @future

// DML in synchronous context
insert contact;

// Callout in separate transaction
makeAsyncCallout('/api/sync', JSON.serialize(payload));

Solution 3: Reverse Order (Callout First)

// Make callout first
HttpResponse response = RestIntegrationService.post('/api/sync', payload);

// Then perform DML
insert contact;

Best Practice

Always use Queueable or @future when you need both DML and callouts. This ensures:

Related Patterns: Asynchronous Apex Patterns

Testing Callout Best Practices

Always implement comprehensive test coverage for your callouts with proper mocking.

Pattern 1: HTTP Callout Mock

@isTest
private class IntegrationServiceTest {
    
    @isTest
    static void testSuccessfulCallout() {
        Test.setMock(HttpCalloutMock.class, new MockHttpResponseGenerator());
        
        Test.startTest();
        HttpResponse response = RestIntegrationService.get('/api/test');
        Test.stopTest();
        
        System.assertEquals(200, response.getStatusCode(), 'Should return 200');
        System.assert(response.getBody().contains('success'), 'Should contain success');
    }
    
    @isTest
    static void testErrorCallout() {
        Test.setMock(HttpCalloutMock.class, new MockHttpErrorGenerator());
        
        Test.startTest();
        try {
            RestIntegrationService.get('/api/test');
            System.assert(false, 'Should throw exception');
        } catch (RestIntegrationService.IntegrationException e) {
            System.assert(e.getMessage().contains('Callout failed'), 'Should throw integration error');
        }
        Test.stopTest();
    }
    
    // Mock HTTP response generator
    private class MockHttpResponseGenerator implements HttpCalloutMock {
        public HTTPResponse respond(HTTPRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody('{"success": true, "data": {"id": "123"}}');
            res.setHeader('Content-Type', 'application/json');
            return res;
        }
    }
    
    private class MockHttpErrorGenerator implements HttpCalloutMock {
        public HTTPResponse respond(HTTPRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(500);
            res.setBody('{"error": "Internal server error"}');
            return res;
        }
    }
}

Pattern 2: Test Multiple Scenarios

@isTest
private class ComprehensiveCalloutTest {
    
    @isTest
    static void testTimeoutScenario() {
        Test.setMock(HttpCalloutMock.class, new MockTimeoutGenerator());
        
        Test.startTest();
        try {
            RestIntegrationService.get('/api/slow');
            System.assert(false, 'Should throw timeout exception');
        } catch (Exception e) {
            System.assert(e.getMessage().contains('timeout'), 'Should handle timeout');
        }
        Test.stopTest();
    }
    
    @isTest
    static void testRetryLogic() {
        Test.setMock(HttpCalloutMock.class, new MockRetryGenerator());
        
        Test.startTest();
        HttpResponse response = ResilientIntegrationService.makeCalloutWithRetry(
            '/api/unstable', 'GET', null
        );
        Test.stopTest();
        
        System.assertEquals(200, response.getStatusCode(), 'Should succeed after retry');
    }
    
    @isTest
    static void testCircuitBreaker() {
        // Test circuit opening after threshold
        for (Integer i = 0; i < 5; i++) {
            Test.setMock(HttpCalloutMock.class, new MockErrorGenerator());
            try {
                RestIntegrationService.makeCallout('/api/failing', 'GET', null);
            } catch (Exception e) {
                // Expected
            }
        }
        
        // Circuit should now be open
        Test.startTest();
        try {
            RestIntegrationService.makeCallout('/api/failing', 'GET', null);
            System.assert(false, 'Should fail fast when circuit is open');
        } catch (IntegrationException e) {
            System.assert(e.getMessage().contains('Circuit breaker'), 'Should indicate circuit open');
        }
        Test.stopTest();
    }
}

Testing Best Practices

Related Patterns: Governor Limits and Optimization - Performance optimization

Q&A

Q: What are the best practices for making API callouts in Salesforce?

A: Best practices include: (1) Use Named Credentials (secure credential management), (2) Implement retry logic (exponential backoff for transient failures), (3) Use circuit breakers (prevent cascading failures), (4) Handle errors gracefully (catch exceptions, log errors), (5) Use async patterns (Queueable/@future for DML + callout), (6) Monitor callout health (track metrics, alert on failures), (7) Test with mocks (never make real callouts in tests). Following these practices ensures reliable, resilient integrations.

Q: Why should I use Named Credentials instead of hardcoding credentials?

A: Named Credentials provide: (1) Secure storage (credentials stored securely, not in code), (2) No code changes (update credentials without code changes), (3) Certificate management (manage SSL certificates), (4) IP whitelisting (configure allowed IPs), (5) Audit trail (track credential usage). Hardcoding credentials creates security risks and maintenance issues.

Q: How do I implement retry logic for callouts?

A: Implement retry logic by: (1) Exponential backoff (increase delay between retries: 1s, 2s, 4s, 8s), (2) Retry only transient failures (5xx errors, timeouts), (3) Limit retry attempts (max 3-5 retries), (4) Use Queueable (retry in separate transaction), (5) Log retry attempts (track retry history). Retry logic handles transient failures without overwhelming external systems.

Q: What is a circuit breaker pattern and when should I use it?

A: Circuit breaker pattern prevents cascading failures by: (1) Opening circuit (stop making callouts after failure threshold), (2) Failing fast (return error immediately when circuit open), (3) Half-open state (test if service recovered), (4) Closing circuit (resume normal operation when service healthy). Use circuit breakers for critical integrations where failures can cascade to other systems.

Q: Why can’t I perform DML before a callout in the same transaction?

A: Salesforce does not allow DML before callouts in the same transaction to prevent data inconsistency. If callout fails after DML, data is committed but external system isn’t updated. Solution: Use Queueable or @future to separate DML and callouts into different transactions, ensuring data consistency.

Q: How do I test callouts without making real API calls?

A: Test callouts using: (1) HttpCalloutMock (implement HttpCalloutMock interface), (2) Test.setMock() (set mock for test context), (3) Mock different scenarios (success, error, timeout), (4) Test retry logic (mock transient failures), (5) Test circuit breaker (mock failures to open circuit). Never make real callouts in tests - always use mocks.

Q: How do I monitor callout health and performance?

A: Monitor by: (1) Log all callouts (log success and failure cases), (2) Track metrics (duration, status codes, response sizes), (3) Monitor failure rates (alert on high failure rates), (4) Track circuit breaker state (monitor when circuits open/close), (5) Correlate requests (use request IDs for tracing), (6) Create dashboards (visualize callout health). Monitoring enables proactive issue detection and resolution.

Q: What are common callout anti-patterns to avoid?

A: Common anti-patterns: (1) Hardcoding credentials (use Named Credentials), (2) No retry logic (fail immediately on transient errors), (3) DML before callout (causes transaction errors), (4) No error handling (exceptions not caught), (5) No monitoring (no visibility into callout health), (6) Synchronous callouts in triggers (blocks user, hits limits), (7) Making real callouts in tests (use mocks). Avoiding these patterns ensures reliable integrations.