Error Handling in Apex: Beyond Try-Catch
A finance analyst named Priya emailed me at the end of a quarter with a question that made my stomach drop: why were roughly 2,800 orders sitting in Salesforce that had never made it into the ERP. I went looking, and the answer was four lines of Apex in an integration handler — a try, a callout, a catch (Exception e), and an empty block with a comment that said // TODO: handle this. It had been swallowing failures quietly for about seven weeks. Nobody noticed because nothing broke. That's the thing about bad error handling — it doesn't crash, it lies.
Most Apex developers learn try-catch early and stop there. But wrapping risky code in a try block is the beginning of error handling, not the whole of it. What you do inside the catch, what you throw, where the failure ends up, and whether anyone finds out — that's the part that matters in production.
Try-Catch Is the Floor, Not the Ceiling
A try-catch decides what happens when something goes wrong. That's a design decision, and most of the time it's made by accident. The developer wraps the code because the compiler or a reviewer nudged them to, catches Exception because it's the broadest type, and then has no plan for the catch block. So it either rethrows blindly, logs to System.debug, or does nothing.
The useful questions are the ones the catch block forces you to answer. Should this failure stop the whole transaction or let the rest continue. Does a human need to act on it, or is it expected noise. Will anyone ever see it after the debug log rolls off. If you can't answer those, the try-catch is decoration.
Custom Exceptions Give the Catch Block Something to Say
Catching bare Exception is like a doctor diagnosing "illness." It's technically correct and completely useless for treatment. I define custom exception types for the failure modes my code actually produces, and I catch those specifically.
Defining one is trivial — Apex only requires that the class extend Exception and end in Exception:
public class IntegrationException extends Exception {}
public class ValidationException extends Exception {}
The value shows up at the call site. Instead of a catch block that treats a validation problem and a dead callout identically, you separate them:
try {
ErpClient.pushOrders(orders);
} catch (Integration.CalloutException e) {
// transient — the ERP was down, retry later
ErpRetryQueue.enqueue(orders);
} catch (ValidationException e) {
// permanent — the data is wrong, a human must fix it
Logger.error(e, 'ErpClient.pushOrders');
}
The retry logic and the "get a human involved" logic are different responses to different problems. Bare Exception collapses them into one branch, and you end up retrying data that will never succeed while dropping outages on the floor. Custom exceptions also make tests honest — you can assert that a specific method throws ValidationException and mean it, which is exactly the kind of assertion I argue for in Apex test patterns that catch real bugs.
A Swallowed Exception Is a Bug You Haven't Found Yet
This is the hill I'll die on for error handling. An empty catch block is worse than no try-catch at all. At least an uncaught exception fails loudly, rolls the transaction back, and leaves a stack trace. A swallowed one lets the transaction commit in a half-finished state and reports success to whoever called it.
That's what happened to Priya's 2,800 orders. The callout to the ERP was failing — expired credentials, as it turned out — and the empty catch meant the Apex kept going, marked the orders as processed, and returned cleanly. From the outside everything was green. The failure only surfaced when finance reconciled the two systems by hand at quarter end.
We fixed the credentials in an afternoon. Backfilling seven weeks of orders took most of a week, and the fix I'm least proud of is the reconciliation report we bolted on afterward to catch the next time this happens, because I didn't trust that we'd found every silent catch in the codebase. We hadn't rebuilt the integration properly. We added a safety net and moved on, and that report is still running today.
If you never intend to handle an exception, don't catch it. Let it propagate. A loud failure you can see beats a quiet one you can't.
Errors Belong in a Record, Not the Debug Log
System.debug is not error handling. Debug logs are ephemeral, capped in size, and off unless someone is actively watching. An error that only exists in a debug log that expired two days ago never happened, as far as anyone troubleshooting is concerned.
I persist errors to a custom object — Error_Log__c — so they survive, so they're reportable, and so I can put a list view in front of the support team. The logger is deliberately boring:
public with sharing class Logger {
public static void error(Exception e, String context) {
insert new Error_Log__c(
Context__c = context,
Message__c = e.getMessage(),
Exception_Type__c = e.getTypeName(),
Stack_Trace__c = e.getStackTraceString(),
Line_Number__c = e.getLineNumber()
);
}
}
getStackTraceString() and getLineNumber() are the fields people forget, and they're the ones that turn a two-hour investigation into a two-minute one. The context string matters too — pass the class and method name so you're not guessing where in the org a failure originated. A message that just says List index out of bounds: 0 with no context is barely better than silence.
Logs Have to Survive the Rollback That Caused Them
Here's the trap in the logger above, and it's the one that bites people who think they've solved logging. If your error occurs inside a transaction that then throws, every DML in that transaction rolls back — including the insert of your Error_Log__c record. You catch the exception, write the log, rethrow so the transaction fails correctly, and the log you just wrote vanishes with everything else. You've built a logging system that only works when you don't need it.
Platform events are the way out, because an event published with a "Publish Immediately" behaviour fires independently of the surrounding transaction's success. The event survives even when the DML around it is rolled back:
public static void error(Exception e, String context) {
EventBus.publish(new Error_Event__e(
Context__c = context,
Message__c = e.getMessage(),
Stack_Trace__c = e.getStackTraceString()
));
}
A separate Error_Event__e trigger subscribes to the event and writes the Error_Log__c record in its own transaction, which isn't rolled back by the failure that produced it. It's more moving parts than a plain insert, and I don't reach for it on simple synchronous code where a rollback would take the log with the thing being logged anyway. But for anything that must record a failure and then rethrow — orchestration, integrations, batch jobs — it's the only pattern that actually delivers the log.
Let the Database Tell You Which Records Failed
Bulk DML has its own error-handling model, and try-catch around a plain insert throws away most of the information. When you insert 200 records and one violates a validation rule, an all-or-nothing insert rolls back all 200 and hands you a single exception. Often that's wrong — you want the 199 good records saved and a precise account of the one that failed.
The Database methods with allOrNone set to false give you that:
Database.SaveResult[] results = Database.insert(records, false);
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
for (Database.Error err : results[i].getErrors()) {
Logger.error(err.getStatusCode() + ': ' + err.getMessage()
+ ' on ' + records[i].Id, 'Bulk order insert');
}
}
}
You get a result per record, in order, telling you exactly which ones failed and why. The trade-off is real: partial success means your transaction is now in a partly-committed state, and you have to decide whether that's acceptable for the process. Sometimes it is. Sometimes you want the savepoint-and-rollback pattern instead, so a failure anywhere unwinds cleanly:
Savepoint sp = Database.setSavepoint();
try {
insert orders;
insert orderLines;
} catch (DmlException e) {
Database.rollback(sp);
Logger.error(e, 'OrderService.create');
throw new OrderException('Could not create order', e);
}
The mirror image of this is validation on the way in. In triggers, don't throw exceptions to reject bad data — use addError, which the platform surfaces on the record and, in bulk, against the specific rows that failed rather than killing the whole batch:
for (Opportunity opp : Trigger.new) {
if (opp.StageName == 'Closed Won' && opp.Amount == null) {
opp.Amount.addError('Amount is required to close an opportunity.');
}
}
Field-level addError puts the message right on the field in the UI, which is a far better experience than a raw exception page. This is exactly the sort of logic I keep in trigger handler classes rather than the trigger itself, for the reasons I laid out in a trigger framework that actually scales — validation is business logic, and business logic doesn't belong in a five-line trigger.
When Try-Catch Isn't the Answer
Not every failure should be caught. If a required piece of reference data is missing and the process genuinely cannot continue, catching and logging just hides a configuration problem behind a friendlier error. Sometimes the correct behaviour is to let it throw, roll back, and force someone to fix the root cause. Catching an exception you can't meaningfully respond to is a slower form of swallowing it.
Async code is where this gets sharper. In a Queueable or a Batch, there's no user staring at a screen to see the error, and an uncaught exception fails the job silently unless you've wired up failure handling deliberately. Error handling in async Apex is a different discipline — retry limits, chaining, and the fact that a failed job just disappears from most people's view — and I've written about it separately in async Apex: Queueable vs Batch vs Future. The short version: the further your code runs from a human, the more work your error handling has to do.
The other thing I'd say, having cleaned up more of these than I'd like, is that error handling is easiest to get right when someone looks at it before it ships rather than after it's swallowed 2,800 records. If you want a second pair of eyes on how your org handles failure, that's the kind of review I do — the empty catch blocks are usually the cheapest thing to find and the most expensive thing to leave.
Good error handling is invisible when everything works and precise when it doesn't. Bad error handling is the reverse — silent when it should scream, and useless when you finally come looking.
Liked this?
New essays in your inbox when I publish. No cadence, no spam.
You might also like
Apex Test Patterns: How to Write Tests That Actually Catch Bugs
Most Apex tests pass coverage and catch nothing. The patterns that actually catch bugs — test data factories, behaviour assertions, governor limit checks, and the bulk cases everyone skips.
Read →SOQL Patterns That Won't Kill Your Governor Limits
The SOQL antipatterns I keep finding in code review, the patterns that replace them, and the trade-offs nobody explains — from years of production Salesforce.
Read →Async Apex Patterns: Queueable vs Batch vs Future — The Decision Tree
Choosing between Queueable, Batch Apex, and Future methods is the asynchronous decision most Salesforce devs get wrong. Here's the framework I use, and the cases where the obvious choice is wrong.
Read →