SfSkills Domains Docs GitHub

Home / apex / error-handling-framework

error-handling-framework

Use when designing a cross-cutting Apex error handling framework: custom exception hierarchies, centralized rollback-safe logging via Platform Events, BatchApexErrorEvent processing, correlation ID threading across async, Error_Log__c design, AuraHandledException boundary. NOT for a single try/catch or DmlException question — use apex/exception-handling. NOT for dead-letter, circuit-breaker and event-replay design in an integration — use integration/error-handling-in-integrations.

Open the skill folder on GitHub


Error Handling Framework

Use this skill when Apex error handling needs to be a deliberate, reusable design — not individual try/catch blocks scattered across classes. The goal is a framework that survives transaction rollback, surfaces structured data for monitoring, enables error-type branching without string parsing, and threads correlation IDs across async boundaries.


Before Starting

Gather this context before designing or reviewing a framework:


Questions to Ask Before Configuring

Ask these before the first class is written. Each one traces to a gotcha in references/gotchas.md that the answer prevents; an LLM that skips them produces a framework that logs beautifully on the paths that were never going to lose the log anyway.

AskWhy it mattersWhat a good answer addsTraces to
"Which entry points can die on a governor limit rather than a business error?"Uncatchable exceptions skip catch and finally alike, so a try/catch logger produces nothing for exactly the failures that most need a recordThe list of entry points that need Database.RaisesPlatformEvents or a Finalizer instead of a catch blockGotcha 7
"Is ErrorLog__e set to publish immediately or after commit, and who can show me the event definition?"The setting lives on the event, not in the Apex, so rollback safety cannot be assessed from the publisher class — and the two behaviors bill to different limitsThe durability claim becomes testable, and the DML budget for a bulk failure becomes calculableGotcha 10
"How many failures can one execution realistically produce — one, or the whole scope?"A 200-record scope where everything fails puts 200 rows through the subscriber, which has its own limits and fails silently when it hits themThe bulk-collection requirement at each call site, and a monitoring threshold on failed deliveriesGotcha 1
"What must the payload contain, and is any of it the exception object itself?"From API 63.0 serializing an exception throws, replacing the failure being logged with a JSON errorA payload built from extracted scalars, and a field-level decision about what is safe to storeGotcha 8
"Who starts the async jobs — Apex, or a Flow or Quick Action?"An invocable entry point has nowhere to put a correlation ID unless the signature is designed for it, and retrofitting the constructor later is a breaking changeThe correlation ID source for every async path, decided before the constructors are frozenGotcha 6
"When a write fails, does anything retry — and with the same in-memory records?"Rolling back to a savepoint leaves Ids on the discarded records, so the obvious retry fails on every row with an error about Ids rather than about the original problemAn explicit re-query step in the retry path, or a decision that there is no retryGotcha 11
"Who reads the error message — a support engineer, or the person who clicked the button?"The AuraHandledException message reaches the browser verbatim, and DML messages carry the org's field namesTwo distinct message surfaces: a full record for support, a correlation ID for the userGotcha 5

What a proper framework adds over scattered try/catch: the failures that destroy transactions are recorded rather than only the ones that were already survivable, every log row can be joined back to the transaction that produced it, and the message the user sees is a deliberate sentence rather than whatever the platform happened to say.


Core Concepts

Concept 1: Log-via-Platform-Event Is the Only Rollback-Safe Logging Pattern

Standard DML (insert errorLog;) inside a failing transaction rolls back with the transaction. If a Batch execute method throws an uncaught exception, any Error_Log__c records inserted during that chunk are lost. A Platform Event published via EventBus.publish() can survive transaction rollback — but only on the right publish behavior, and the Apex source does not show which one the org configured. The limits table treats the two as different operations: publish-immediately events have their own ceiling of 150 EventBus.publish calls per transaction (apexdev L19598-19599), while publish-after-commit events count against the transaction's DML statements (apexdev L19635). UNVERIFIED (2026-09-12): the Apex Developer Guide does not state what an after-commit event does when the publishing transaction rolls back; the Platform Events Developer Guide is the authority, and the answer decides whether this pattern is durable at all. Read the event definition before claiming rollback safety — see references/gotchas.md Gotcha 10. This makes the pattern: catch the exception, build a log Platform Event payload, publish it with EventBus.publish(), and let a subscriber trigger insert the Error_Log__c record in a fresh transaction. The Error_Log__c write happens in the subscriber's independent transaction and cannot be rolled back by the caller's failure.

This is not a performance optimization but a durability decision, and it is not the only one available: a Finalizer attached to a Queueable runs in a separate Apex and Database transaction (apexdev L16296-16297), so DML inside it is outside the range the parent's rollback discards. references/code-examples.md builds the whole failure path that way, with no Platform Event definition at all.

Concept 2: BatchApexErrorEvent Fires Automatically on Batch Failures

From API v44.0 onward, Salesforce automatically publishes a BatchApexErrorEvent Platform Event whenever a Batch Apex execute, start, or finish method throws an unhandled exception. The event contains AsyncApexJobId, ExceptionType, Message, StackTrace, DoesExceedJobScopeMaxLength, JobScope, and Phase. An org subscribes to this event with an Apex trigger on BatchApexErrorEvent. This means batch error capture does not require custom try/catch inside every batch class — the platform produces the structured event automatically. Custom try/catch inside execute is still appropriate for expected, recoverable failures (e.g., integration timeouts on specific records), but the safety net for unhandled exceptions is automatic.

Important: DoesExceedJobScopeMaxLength is true when the set of record IDs in the failed scope exceeds the field character limit. In that case JobScope is truncated and the full scope must be retrieved separately via AsyncApexJob query if needed.

Concept 3: AuraHandledException Must Stay at the Controller Boundary

AuraHandledException is a transport mechanism for delivering a human-readable error message to an Aura component or LWC — it is not a base class for domain exceptions. When thrown deep inside a service or selector class, it couples business logic to UI transport and suppresses the Apex stack trace: the platform treats AuraHandledException as a handled error and does not include the full server-side stack trace in the response. The correct pattern is: service classes throw typed domain exceptions; the Aura/LWC controller catches domain exceptions, logs them using the framework, and wraps the message into a new AuraHandledException constructed at the controller layer only. Every layer below the controller should remain unaware of AuraHandledException.

Concept 4: Typed Exception Hierarchy with Error Code Enum

Branching on exception message strings (e.g., if (e.getMessage().contains('REQUIRED_FIELD_MISSING'))) is fragile and breaks on message text changes. A typed exception hierarchy backed by an error code enum lets callers branch on type or code. The pattern: define an AppException base extending Exception, add child classes per domain (IntegrationException, ValidationException, ConfigurationException), and embed an ErrorCode enum (REQUIRED_FIELD_MISSING, DUPLICATE_VALUE, CALLOUT_TIMEOUT, etc.). Callers catch the specific type or check exception.code without string parsing. The error code flows into the log record's Error_Code__c field for metric aggregation.

Concept 5: Correlation ID Must Be Threaded Manually Through Async State

When a synchronous transaction enqueues a Queueable or starts a Batch job, there is no automatic link from the async job's log entries back to the originating record ID, user session, or transaction. AsyncApexJob.ExtendedStatus has a 255-character limit and is not designed for structured data. The correct pattern is to pass a correlationId String into the Queueable or Batch constructor, store it as an instance variable, include it in every EventBus.publish() call from that context, and populate Correlation_Id__c on the Error_Log__c record. The consumer of the log can then query by correlation ID to assemble the full execution trace across transaction boundaries.


Common Patterns

Pattern 1: Platform Event Log Publisher

When to use: Any Apex context that needs durable error logging — especially trigger handlers, batch execute, and queueable execute methods where the surrounding transaction may roll back.

How it works:

Define an ErrorLog__e Platform Event with fields: Level__c (Text: INFO, WARN, ERROR, FATAL), Context__c (Text: classname + method), Message__c (Text Area), Stack_Trace__c (Long Text Area), Payload__c (Long Text Area for JSON), Correlation_Id__c (Text).

In the ErrorLogger utility class:

public class ErrorLogger {
    public static void logException(
        String level,
        String context,
        Exception ex,
        String correlationId,
        String payloadJson
    ) {
        ErrorLog__e evt = new ErrorLog__e(
            Level__c       = level,
            Context__c     = context,
            Message__c     = ex.getMessage(),
            Stack_Trace__c = ex.getStackTraceString(),
            Correlation_Id__c = correlationId,
            Payload__c     = payloadJson
        );
        List<Database.SaveResult> results = EventBus.publish(new List<ErrorLog__e>{ evt });
        // SaveResult failure here means the PE limit was hit or field validation failed.
        // Log to System.debug as the absolute last resort — never DML here.
        for (Database.SaveResult sr : results) {
            if (!sr.isSuccess()) {
                System.debug(LoggingLevel.ERROR,
                    'ErrorLogger: failed to publish ErrorLog__e — ' + sr.getErrors());
            }
        }
    }
}

A trigger on ErrorLog__e then inserts Error_Log__c records in a fresh, independent transaction.

Why not the alternative: Inserting Error_Log__c directly in the catch block loses the log if the enclosing transaction rolls back.

Pattern 2: Typed Domain Exception Hierarchy

When to use: Any service layer, selector, or integration class that needs to signal a specific failure type to its callers.

How it works:

public class AppException extends Exception {
    public enum ErrorCode {
        REQUIRED_FIELD_MISSING,
        DUPLICATE_VALUE,
        CALLOUT_TIMEOUT,
        INTEGRATION_AUTH_FAILURE,
        CONFIGURATION_MISSING,
        UNEXPECTED
    }
    public ErrorCode code { get; private set; }

    public AppException(ErrorCode code, String message) {
        this(message);
        this.code = code;
    }
}

public class IntegrationException extends AppException { }
public class ValidationException extends AppException { }
public class ConfigurationException extends AppException { }

Callers branch by type:

try {
    integrationService.callExternalAPI(payload);
} catch (IntegrationException ie) {
    if (ie.code == AppException.ErrorCode.CALLOUT_TIMEOUT) {
        // enqueue retry
    } else {
        // surface to user
        throw new AuraHandledException(ie.getMessage());
    }
} catch (AppException ae) {
    ErrorLogger.logException('ERROR', 'MyController.doWork', ae, correlationId, null);
    throw new AuraHandledException('An unexpected error occurred. Reference: ' + correlationId);
}

UNVERIFIED (2026-09-12): the this(message) chaining in the AppException(ErrorCode, String) constructor above. The Apex Developer Guide documents the four generated constructor forms for an exception class — no-arg, String, Exception, and (String, Exception) (apexdev L40167-40180) — and says nothing about chaining into them from a custom constructor. Question to settle in a scratch org before adopting this form: does it compile at API 67.0? references/code-examples.md § 2 uses the construct-then-assign form instead, which needs no undocumented behavior.

Why not the alternative: Catching Exception and parsing getMessage() for string keywords is fragile and produces inconsistent behavior when Salesforce changes system exception message text.

Pattern 3: BatchApexErrorEvent Subscriber

When to use: Any org running Batch Apex jobs that need automatic capture of unhandled batch failures without wrapping every execute method in try/catch.

How it works:

trigger BatchApexErrorEventTrigger on BatchApexErrorEvent (after insert) {
    for (BatchApexErrorEvent evt : Trigger.new) {
        // Publish to ErrorLog__e so the same subscriber writes the Error_Log__c record.
        ErrorLog__e logEvt = new ErrorLog__e(
            Level__c       = 'ERROR',
            Context__c     = 'BatchApexErrorEvent:' + evt.Phase,
            Message__c     = evt.Message,
            Stack_Trace__c = evt.StackTrace,
            Payload__c     = JSON.serialize(new Map<String, Object>{
                'AsyncApexJobId' => evt.AsyncApexJobId,
                'ExceptionType'  => evt.ExceptionType,
                'DoesExceedJobScopeMaxLength' => evt.DoesExceedJobScopeMaxLength,
                'JobScope'       => evt.JobScope
            }),
            Correlation_Id__c = evt.AsyncApexJobId
        );
        EventBus.publish(logEvt);
    }
}

Why not the alternative: Adding try/catch to every batch execute method for unhandled failures is error-prone and relies on individual developers remembering to do it. The BatchApexErrorEvent safety net is automatic and catches even NullPointerException and LimitException failures that a developer may not anticipate.

Pattern 4: Correlation ID Threading in Queueable and Batch

When to use: Any async job whose failures or logs need to be traceable back to an originating record, user action, or parent transaction.

How it works:

public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
    private final String correlationId;
    private final List<Id> accountIds;

    public AccountSyncQueueable(List<Id> accountIds, String correlationId) {
        this.accountIds = accountIds;
        this.correlationId = correlationId;
    }

    public void execute(QueueableContext ctx) {
        try {
            // ... business logic ...
        } catch (IntegrationException ie) {
            ErrorLogger.logException('ERROR', 'AccountSyncQueueable.execute', ie,
                correlationId, JSON.serialize(accountIds));
        }
    }
}

The caller passes correlationId:

String corrId = UserInfo.getUserId() + ':' + String.valueOf(Datetime.now().getTime());
System.enqueueJob(new AccountSyncQueueable(accountIds, corrId));

Why not the alternative: Without an explicit correlation ID, logs from the async job have no link to the triggering event, making post-failure diagnosis require manual timestamp correlation across debug logs and log records.


Decision Guidance

SituationRecommended ApproachReason
Need durable logging inside a transaction that may roll backPublish ErrorLog__e Platform Event via EventBus.publish()PE publish survives rollback; subscriber inserts log in fresh transaction
Batch job needs automatic unhandled error captureSubscribe to BatchApexErrorEvent triggerPlatform publishes the event automatically from API v44+
LWC controller needs to show a safe error messageCatch domain exception in controller, wrap message in new AuraHandledException(msg) at controller onlyAuraHandledException thrown in service layer suppresses stack trace and couples transport to business logic
Service needs to signal specific failure typeThrow typed AppException subclass with ErrorCode enumEnables catch-by-type branching without fragile string parsing
Async job logs need to be traceable to originating recordsPass correlationId into job constructor, include in every log eventPlatform provides no automatic correlation across transaction boundaries
Integration HTTP call returns error bodyParse JSON error body for vendor error codes, map to IntegrationException with appropriate ErrorCodeRaw HTTP status code alone does not distinguish timeout from auth failure from quota exceeded

  1. Inventory entry points and logging state — list all Apex entry points (triggers, controllers, REST, Queueable, Batch, Scheduled), confirm API version is v44+ for BatchApexErrorEvent, and document what logging mechanism (if any) is already deployed.
  2. Design the Error_Log__c object and ErrorLog__e Platform Event — agree on required fields: Level__c, Context__c, Message__c, Stack_Trace__c, Payload__c, Correlation_Id__c, Error_Code__c. Create the PE and a trigger on it that inserts Error_Log__c in the subscriber transaction.
  3. Implement the typed exception hierarchy — define AppException with ErrorCode enum, add domain subclasses (IntegrationException, ValidationException, ConfigurationException). Document which codes map to which retry or escalation behavior.
  4. Implement ErrorLogger utility class — single static method that accepts level, context, exception, correlationId, and optional payload JSON, then calls EventBus.publish(). Never do DML inside this class.
  5. Add BatchApexErrorEvent trigger — subscribe to BatchApexErrorEvent, extract AsyncApexJobId, ExceptionType, Message, StackTrace, Phase, JobScope, and route through ErrorLogger so batch failures land in the same Error_Log__c store.
  6. Enforce AuraHandledException boundary — review all Aura/LWC controllers and ensure AuraHandledException is constructed only at the controller layer. Service classes should throw AppException subclasses. Add this as a code review rule.
  7. Validate — run python3 skills/apex/error-handling-framework/scripts/check_error_handling_framework.py --manifest-dir <source dir> over the Apex source (exit 0 and No issues found., exit 1 with one ISSUE: line per finding; a directory holding no .cls or .trigger file is a failure, not a pass), then confirm in a sandbox that log records appear after a synchronous failure, a batch failure, and a job killed by a governor limit, and that correlation IDs are populated on the async entries.

Review Checklist


Salesforce-Specific Gotchas

  1. Platform Event publish can fail silently if the subscriber hits limits — EventBus.publish() returns Database.SaveResult[]. If publish succeeds but the subscriber trigger hits CPU or DML limits, the Error_Log__c insert fails and the error is lost. Monitor subscriber failures in Setup > Platform Events > Subscriptions and set up BatchApexErrorEvent to catch failures in the subscriber itself.
  2. BatchApexErrorEvent.JobScope is truncated when DoesExceedJobScopeMaxLength is true — for large batch scopes the comma-separated ID list exceeds the field character limit. Always check DoesExceedJobScopeMaxLength before using JobScope and fall back to querying AsyncApexJob by AsyncApexJobId for the full scope.
  3. AuraHandledException thrown in service classes loses the Apex stack trace — the platform treats it as an intentional, user-safe error and does not include server-side stack trace in the Lightning response. Developers who throw it in service layers to "pass the message up" end up unable to diagnose where the exception originated in production.
  4. Typed exceptions require explicit code assignment in the constructor — Apex's generated Exception constructors do not call custom constructors. Always use the explicit AppException(ErrorCode, String) constructor form; calling new IntegrationException('message') without the error code leaves code null and breaks downstream branching.
  5. A limit failure reaches no catch block — governor-limit terminations are uncatchable, so the logging call in the catch, and in the finally if there is one, never executes. Batch classes get a platform substitute through Database.RaisesPlatformEvents; Queueables get one through a Finalizer; synchronous entry points get neither, and must be budgeted so they cannot reach the ceiling. Depth in references/gotchas.md Gotcha 7.

Output Artifacts

ArtifactDescription
Exception hierarchy designAppException base class, ErrorCode enum, domain subclass list
ErrorLog__e Platform Event schemaField names, types, and PE configuration
Error_Log__c field schemaCustom object design with all required fields
ErrorLogger utility classRollback-safe logging implementation
BatchApexErrorEvent triggerAutomatic batch failure capture pattern
Correlation ID threading planWhich jobs need it, constructor signature, query pattern for log retrieval
Code review findingsController boundary violations, missing correlation IDs, DML in catch blocks

Reference Files

FileRead it when
references/code-examples.mdYou are writing the code — a contract-amendment service with a custom exception tree, partial-success DML captured one row per rejection, a Savepoint path, a Queueable whose Finalizer logs through a governor limit, a test class of negative paths, the -meta.xml and package.xml, deploy order, and how to run the checker
references/gotchas.mdSomething already behaves oddly — silent subscriber failures, truncated job scope, a log that never appeared, a retry that fails on Ids
references/llm-anti-patterns.mdYou are reviewing generated code, or checking your own output before handing it over
references/examples.mdYou want the narrative walk-through of the Platform Event logging path rather than a deployable package
references/well-architected.mdYou are justifying the design, or need the source behind a specific claim

Package files