Error Strategy in Salesforce Apex: Custom Exceptions, Fail-Fast Validation, Correlation IDs & Telemetry

Share

In Salesforce Apex, building a reliable error-handling strategy means doing more than just catching exceptions. You need to:

  • Fail fast to block bad data before it hits the database,

  • Use custom exceptions that make intent clear,

  • Tag each transaction with a correlation ID for traceability, and

  • Emit structured telemetry for real-time observability.

Together, these practices keep your data clean, make failures understandable for users, and give your ops team immediate visibility from the UI to the logs and event stream.


? 1) Fail-Fast Validation — Stop Bad Data Early

What & Why:
Validate data before any DML or callouts. Failing fast avoids partial writes, wasted governor limits, and data inconsistencies.

Real Example:
If an agent tries to submit an invoice with Amount__c = 0, reject it immediately — don’t wait for a downstream failure.

public with sharing class InvoiceValidator {
    public static void validate(Invoice__c inv) {
        if (inv == null) throw new AuraHandledException('Invoice payload is required');
        if (inv.Amount__c == null || inv.Amount__c <= 0)
            throw new AuraHandledException('Amount must be > 0');
        if (String.isBlank(inv.CurrencyIsoCode))
            throw new AuraHandledException('Currency is required');
        if (String.isBlank(inv.CardLast4__c) || inv.CardLast4__c.length() != 4)
            throw new AuraHandledException('card_last4 must be 4 digits');
    }
}

? 2) Custom Exceptions — Express Business Intent

What & Why:
Not all failures are system bugs — some are valid business outcomes. Create domain-specific exception classes to separate those from true errors.

Real Example:
If your payment gateway returns “limit exceeded,” throw a PaymentDeclinedException so the UI can guide users to retry later instead of showing a generic error.

public class PaymentDeclinedException extends Exception {
    public String reason;
    public Integer retryAfterSeconds;
    public PaymentDeclinedException(String reason, Integer retryAfterSeconds) {
        this.reason = reason; this.retryAfterSeconds = retryAfterSeconds;
    }
}

? 3) Correlation IDs — Trace a Request End-to-End

What & Why:
Attach a unique correlation ID (like Limits.getRequestId()) to every transaction. Include it in logs, platform events, and API responses. When a customer reports an issue, your ops team can jump straight to the right record in your logs or telemetry.

Real Example:
Support asks for the correlationId shown in the UI; within seconds, you can find the exact API request and exception in logs.

public with sharing class Correlation {
    public static String current() { return Limits.getRequestId(); }
    public static void logInfo(String msg, Map<String,Object> fields) {
        fields = fields == null ? new Map<String,Object>() : fields;
        fields.put('cid', current());
        System.debug(LoggingLevel.INFO, JSON.serialize(fields) + ' :: ' + msg);
    }
}

? 4) Telemetry via Platform Events — Make Errors Observable

What & Why:
Don’t rely on log scraping. Publish structured Platform Events for real-time monitoring and alerts. This makes your system observable by design.

Real Example:
If PaymentDeclined events spike after a gateway update, you’ll know instantly and can trigger an alert.

Platform Event: Error_Telemetry__e
Fields: CorrelationId__c, ErrorType__c, Message__c, Context__c

public with sharing class Telemetry {
    public static void error(String cid, String type, String message, String context) {
        EventBus.publish(new Error_Telemetry__e(
            CorrelationId__c = cid,
            ErrorType__c     = type,
            Message__c       = message.left(240),
            Context__c       = context.left(240)
        ));
    }
}


? 5) Service Layer — Combine Validation and Business Logic

What & Why:
Keep your triggers lightweight and push logic into a service layer. Validate early and throw meaningful exceptions when business rules fail.

Real Example:
If B2B orders above £500 require manual review, throw a PaymentDeclinedException immediately instead of letting it fail downstream.

public with sharing class PaymentService { public static void charge(Invoice__c inv) { InvoiceValidator.validate(inv); // fail-fast if (inv.Amount__c > 500) { throw new PaymentDeclinedException('LIMIT_EXCEEDED', 3600); } // ...perform safe callout or DML... Correlation.logInfo('Payment processed', new Map<String,Object>{ 'invoiceId' => inv.Id, 'amount' => inv.Amount__c }); } }

? 6) Trigger Handler — Surface Friendly Errors & Emit Telemetry

What & Why:
Catching domain exceptions inside your handler lets you show clear, user-friendly messages while still emitting telemetry for monitoring and analytics.

Real Example:
A nightly job inserts invoices. If some fail, users see clear messages, and ops teams get telemetry events with full context and correlation IDs.

public with sharing class InvoiceTriggerHandler {
    public static void beforeInsert(List<Invoice__c> newList) {
        String cid = Correlation.current();
        for (Invoice__c inv : newList) {
            try {
                PaymentService.charge(inv);
            } catch (PaymentDeclinedException e) {
                Telemetry.error(cid, 'PaymentDeclined', e.reason, 'beforeInsert Invoice ' + inv.Name);
                inv.addError('Payment declined: ' + e.reason +
                    (e.retryAfterSeconds != null ? ' — try again in ' + e.retryAfterSeconds + 's' : '') +
                    ' (ref ' + cid + ')');
            } catch (Exception e) {
                Telemetry.error(cid, 'Unexpected', e.getMessage(), 'beforeInsert Invoice ' + inv.Name);
                inv.addError('Unexpected error. Reference: ' + cid);
            }
        }
    }
}
// trigger InvoiceTrigger on Invoice__c (before insert) { InvoiceTriggerHandler.beforeInsert(Trigger.new); }

? 7) REST API — Return Status Codes & Correlation IDs

What & Why:
When exposing APIs, always map business outcomes to appropriate HTTP response codes and include the correlation ID in every reply. It keeps client systems, users, and ops all in sync.

Real Example:
If a storefront calls /payments and the charge fails, return a 402 status code (Payment Required) with the correlation ID so support can trace it.

@RestResource(urlMapping='/payments')
global with sharing class PaymentApi {
    @HttpPost
    global static void pay() {
        String cid = Correlation.current();
        try {
            Invoice__c inv = (Invoice__c) JSON.deserialize(
                RestContext.request.requestBody.toString(), Invoice__c.class);
            PaymentService.charge(inv);

            RestContext.response.statusCode = 200;
            RestContext.response.responseBody =
                Blob.valueOf('{"status":"ok","correlationId":"' + cid + '"}');
        } catch (PaymentDeclinedException e) {
            RestContext.response.statusCode = 402;
            RestContext.response.responseBody =
                Blob.valueOf('{"error":"declined","reason":"' + e.reason + '","correlationId":"' + cid + '"}');
        } catch (Exception e) {
            RestContext.response.statusCode = 500;
            RestContext.response.responseBody =
                Blob.valueOf('{"error":"unexpected","correlationId":"' + cid + '"}');
        }
    }
}

 

Error Strategy

Error Strategy 2

? Final Thoughts

A robust error-handling strategy in Apex isn’t just about catching exceptions — it’s about designing for clarity, traceability, and resilience.

  • Fail fast to block invalid data before it spreads.

  • Throw custom exceptions that communicate business intent.

  • Tag every request with a correlation ID for full traceability.

  • Emit telemetry events so your system can be monitored and alerted in real time.

This approach gives your users clearer feedback, your developers faster debugging, and your operations team instant insight into what’s happening across your org.

  • October 20, 2025