Apex Resilience, Transactions & Error Contracts
Apex error handling is an API contract—not a try/catch pattern
A catch block can hide a broken transaction
Apex catches a DmlException, writes a debug line and returns false. The UI displays “Something went wrong,” but an earlier update has already succeeded because the exception was absorbed and the request completed normally. Another service sees the changed record, the user retries, and the organisation now has a consistency problem disguised as graceful error handling. The code handled an exception; it did not define a safe outcome.
The JSBC Labs position is that error handling is part of an Apex API contract. A caller needs to know whether work committed, whether the request is safe to repeat, what category of failure occurred and who owns recovery. try/catch is only a language mechanism for implementing that contract. Without explicit transaction semantics, it can suppress the platform behaviour that would otherwise roll back a failed request.
Classify failures by business meaning
Do not let every exception become one generic technical error. Separate invalid input, business-rule rejection, authorization failure, stale-state conflict, unavailable dependency, capacity limit and unexpected defect. The same user message and retry policy cannot serve them all. A validation failure should explain what can be corrected; a conflict should invite a refresh; a transient dependency may support bounded retry; an authorization failure should not.
Use built-in exception types such as DmlException and QueryException where they express the platform cause, and custom exceptions where the domain needs a stable meaning. Keep the mapping at a boundary: domain services should raise domain-relevant failures, while controllers, APIs and asynchronous orchestrators translate them for their consumers. Avoid a giant try block that makes it impossible to know which operation failed or which recovery is safe.
Make the commit promise explicit
Salesforce defines an Apex request as a transaction: if it completes successfully, database changes commit; if it does not, changes roll back. Catching an exception changes that outcome because execution can continue and commit earlier DML. Decide whether the business command is atomic before writing the handler. If confirming an order must update the header, create lines and reserve capacity together, any failed invariant should prevent the complete outcome.
Use savepoints only when the business process genuinely supports a controlled alternative path inside the same request. Salesforce can restore database state to a savepoint, but static variables are not reverted and sObject variables inserted after the savepoint can retain IDs after rollback. Recovery code therefore needs deliberate state management. Rollback is not a general-purpose eraser for arbitrary side effects or remote systems.
Partial success is a product decision
Bulk operations sometimes need independent outcomes. Database methods with allOrNone set to false return SaveResult entries so valid records can succeed while invalid records report errors. Salesforce preserves positional correspondence between input records and results. That capability is valuable for imports and queues, but it deliberately abandons atomicity. The caller must reconcile every result rather than treating the absence of an exception as success.
Define when partial success is allowed, how each item is identified, where failures are recorded and who retries them. Return counts and structured per-item outcomes, not a single Boolean. If the operation represents one business unit—such as all lines of one invoice—partial DML may create an invalid state even though the API supported it. Choose all-or-none semantics from the business invariant, not from a desire to avoid exceptions.
Return a stable and safe client contract
Unhandled Apex errors can expose implementation details such as class names and stack information to a Lightning client. Salesforce supports AuraHandledException for controlled LWC and Aura messages, and its guidance also recommends wrapper classes for standardised error structures. Use a response envelope with a stable code, user-safe message, correlation ID, retry guidance and relevant field or item references.
Do not send raw exception messages, SOQL, record data, endpoint responses or stack traces to users. Preserve those details in protected operational telemetry linked by the correlation ID. Keep error codes stable across refactoring so clients do not depend on English text. A UI can then distinguish “correct these fields,” “refresh this record,” “request access,” and “try later” without knowing which Apex class or database constraint produced the outcome.
Asynchronous failure needs durable ownership
When Queueable, Batch, scheduled or future Apex fails, there may be no waiting user to receive the exception. AsyncApexJob exposes job status and error information for asynchronous work. Queueable Transaction Finalizers can inspect whether the parent completed or ended with an unhandled exception, obtain the job and request IDs, and perform a governed post-action. These are recovery hooks, not permission to retry forever.
Persist the business operation and idempotency key before handing off consequential work. Define maximum attempts, backoff, terminal states and a human exception queue. A finalizer can record failure or schedule a safe retry, but it cannot prove that an external dependency did not process a timed-out request. Reconciliation and idempotency remain necessary wherever the job crosses a system boundary.
Never confuse logging with handling
A catch block that only writes System.debug and continues has neither recovered nor created durable evidence. Handle an expected error only when the code can produce a valid alternative outcome. Otherwise enrich it with safe context and propagate it to the boundary responsible for rollback and classification. Preserve the original cause internally so diagnosis does not begin with a generic message and an empty log.
Capture operation name, correlation ID, execution user, record identifiers permitted by policy, failure category, retry count and asynchronous job ID. Exclude secrets and sensitive payloads. Alert on terminal failures and repeated transient errors, and assign an owner for each integration or domain service. Debug logs expire and may not be enabled; production recovery needs durable, queryable evidence and a response path.
Test outcomes, not just thrown types
Negative tests should assert the database state after failure, not merely that an exception was thrown. Prove that atomic commands leave no partial records, partial-success operations report every input, authorization failures reveal no protected data, and repeated retryable requests remain idempotent. For client boundaries, assert stable codes and safe messages rather than line numbers or implementation-specific exception text.
Exercise asynchronous terminal failure, finalizer behaviour, exhausted retries and reconciliation. Mock expected dependency responses, but also simulate timeouts, malformed payloads and ambiguous outcomes. The standard for production Apex is not that every exception is caught. It is that every expected failure has an honest contract, every unexpected failure preserves transactional safety and evidence, and operators can determine what committed without reconstructing the incident from guesswork.
Official references
- Salesforce Developers — Handle Errors from Apex
- Salesforce Developers — Exception Class and Built-In Exceptions
- Salesforce Developers — SaveResult Class
- Salesforce Developers — Bulk DML Exception Handling
- Salesforce Developers — Transaction Control
- Salesforce Developers — Transaction Finalizers
- Salesforce Developers — FinalizerContext Interface
- Salesforce Developers — AsyncApexJob Field Reference