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:
- What are all the Apex entry points that need coverage? Trigger handlers, Aura/LWC controllers, REST resources, Queueable implementations, Batch classes, and Scheduled jobs each have different failure contracts.
- Does the org have an existing
Error_Log__cobject, Platform Event for logging, or an external observability tool (Datadog, Splunk)? The framework integrates with or replaces what is already there. - Is correlation ID linkage required? If async jobs (Batch, Queueable) need their log entries traceable back to originating records or the parent transaction, that threading must be explicit from the start — the platform does not provide it automatically.
- Is partial success required in bulk operations? The framework logging strategy differs depending on whether
Database.SaveResult[]paths are in scope. - What API version is the org targeting?
BatchApexErrorEventis available from API v44.0 onward.
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.
| Ask | Why it matters | What a good answer adds | Traces 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 record | The list of entry points that need Database.RaisesPlatformEvents or a Finalizer instead of a catch block | Gotcha 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 limits | The durability claim becomes testable, and the DML budget for a bulk failure becomes calculable | Gotcha 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 them | The bulk-collection requirement at each call site, and a monitoring threshold on failed deliveries | Gotcha 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 error | A payload built from extracted scalars, and a field-level decision about what is safe to store | Gotcha 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 change | The correlation ID source for every async path, decided before the constructors are frozen | Gotcha 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 problem | An explicit re-query step in the retry path, or a decision that there is no retry | Gotcha 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 names | Two distinct message surfaces: a full record for support, a correlation ID for the user | Gotcha 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
| Situation | Recommended Approach | Reason |
|---|---|---|
| Need durable logging inside a transaction that may roll back | Publish ErrorLog__e Platform Event via EventBus.publish() | PE publish survives rollback; subscriber inserts log in fresh transaction |
| Batch job needs automatic unhandled error capture | Subscribe to BatchApexErrorEvent trigger | Platform publishes the event automatically from API v44+ |
| LWC controller needs to show a safe error message | Catch domain exception in controller, wrap message in new AuraHandledException(msg) at controller only | AuraHandledException thrown in service layer suppresses stack trace and couples transport to business logic |
| Service needs to signal specific failure type | Throw typed AppException subclass with ErrorCode enum | Enables catch-by-type branching without fragile string parsing |
| Async job logs need to be traceable to originating records | Pass correlationId into job constructor, include in every log event | Platform provides no automatic correlation across transaction boundaries |
| Integration HTTP call returns error body | Parse JSON error body for vendor error codes, map to IntegrationException with appropriate ErrorCode | Raw HTTP status code alone does not distinguish timeout from auth failure from quota exceeded |
Recommended Workflow
- 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. - 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 insertsError_Log__cin the subscriber transaction. - Implement the typed exception hierarchy — define
AppExceptionwithErrorCodeenum, add domain subclasses (IntegrationException,ValidationException,ConfigurationException). Document which codes map to which retry or escalation behavior. - Implement
ErrorLoggerutility class — single static method that accepts level, context, exception, correlationId, and optional payload JSON, then callsEventBus.publish(). Never do DML inside this class. - Add
BatchApexErrorEventtrigger — subscribe toBatchApexErrorEvent, extractAsyncApexJobId,ExceptionType,Message,StackTrace,Phase,JobScope, and route throughErrorLoggerso batch failures land in the sameError_Log__cstore. - Enforce AuraHandledException boundary — review all Aura/LWC controllers and ensure
AuraHandledExceptionis constructed only at the controller layer. Service classes should throwAppExceptionsubclasses. Add this as a code review rule. - Validate — run
python3 skills/apex/error-handling-framework/scripts/check_error_handling_framework.py --manifest-dir <source dir>over the Apex source (exit 0 andNo issues found., exit 1 with oneISSUE:line per finding; a directory holding no.clsor.triggerfile 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
- [ ]
ErrorLog__ePlatform Event exists with Level, Context, Message, Stack_Trace, Payload, Correlation_Id fields. - [ ]
Error_Log__cis inserted by a trigger onErrorLog__e, not by DML inside error-handling catch blocks. - [ ]
ErrorLoggerutility usesEventBus.publish()exclusively — no DML, noinsertstatement inside it. - [ ]
AppExceptionhierarchy defines anErrorCodeenum; callers branch by type or code, not bygetMessage()string parsing. - [ ]
AuraHandledExceptionis constructed only in Aura/LWC controller catch blocks, not in service or selector classes. - [ ]
BatchApexErrorEventtrigger exists and routes unhandled batch failures into the log store. - [ ] All Queueable and Batch job constructors accept a
correlationIdparameter and propagate it to log events. - [ ] Integration error parsing extracts vendor error codes from HTTP response bodies and maps them to
IntegrationExceptionwith appropriateErrorCode.
Salesforce-Specific Gotchas
- Platform Event publish can fail silently if the subscriber hits limits —
EventBus.publish()returnsDatabase.SaveResult[]. If publish succeeds but the subscriber trigger hits CPU or DML limits, theError_Log__cinsert fails and the error is lost. Monitor subscriber failures in Setup > Platform Events > Subscriptions and set upBatchApexErrorEventto catch failures in the subscriber itself. BatchApexErrorEvent.JobScopeis truncated whenDoesExceedJobScopeMaxLengthis true — for large batch scopes the comma-separated ID list exceeds the field character limit. Always checkDoesExceedJobScopeMaxLengthbefore usingJobScopeand fall back to queryingAsyncApexJobbyAsyncApexJobIdfor the full scope.AuraHandledExceptionthrown 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.- Typed exceptions require explicit
codeassignment in the constructor — Apex's generatedExceptionconstructors do not call custom constructors. Always use the explicitAppException(ErrorCode, String)constructor form; callingnew IntegrationException('message')without the error code leavescodenull and breaks downstream branching. - A limit failure reaches no catch block — governor-limit terminations are uncatchable, so the logging call in the
catch, and in thefinallyif there is one, never executes. Batch classes get a platform substitute throughDatabase.RaisesPlatformEvents; Queueables get one through a Finalizer; synchronous entry points get neither, and must be budgeted so they cannot reach the ceiling. Depth inreferences/gotchas.mdGotcha 7.
Output Artifacts
| Artifact | Description |
|---|---|
| Exception hierarchy design | AppException base class, ErrorCode enum, domain subclass list |
ErrorLog__e Platform Event schema | Field names, types, and PE configuration |
Error_Log__c field schema | Custom object design with all required fields |
ErrorLogger utility class | Rollback-safe logging implementation |
BatchApexErrorEvent trigger | Automatic batch failure capture pattern |
| Correlation ID threading plan | Which jobs need it, constructor signature, query pattern for log retrieval |
| Code review findings | Controller boundary violations, missing correlation IDs, DML in catch blocks |
Reference Files
| File | Read it when |
|---|---|
references/code-examples.md | You 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.md | Something already behaves oddly — silent subscriber failures, truncated job scope, a log that never appeared, a retry that fails on Ids |
references/llm-anti-patterns.md | You are reviewing generated code, or checking your own output before handing it over |
references/examples.md | You want the narrative walk-through of the Platform Event logging path rather than a deployable package |
references/well-architected.md | You are justifying the design, or need the source behind a specific claim |
Related Skills
apex/exception-handling— use for individual try/catch block guidance, DmlException semantics,addErrorin triggers, and bulk DML SaveResult patterns. This skill (error-handling-framework) covers the org-wide framework design; exception-handling covers the in-method mechanics.apex/platform-events-apex— use for Platform Event publish/subscribe mechanics, trigger ordering, high-volume events, and replay ID management when building the log subscriber.apex/batch-apex-patterns— use alongside this skill when the batch job design (scope size, stateful vs stateless, job chaining) needs review alongside the error capture strategy.apex/async-apex— use when the right remediation for a failure is moving work to Queueable rather than retrying synchronously.lwc/lwc-error-boundaries— use for the LWC-side pattern:errorCallback, normalising theerror.bodyshapes an Apex controller returns, and the fallback UI that keeps one failed tile from blanking the page.
Package files
- references/code-examples.md
- references/examples.md
- references/gotchas.md
- references/llm-anti-patterns.md
- references/well-architected.md
- templates/error-handling-framework-template.md
- scripts/check_error_handling_framework.py
- scripts/fixtures/comment-only/CommentOnlyErrorHandling.cls
- scripts/fixtures/real-aura-outside-controller/FooService.cls
- scripts/fixtures/real-aura-outside-controller/README.md