Error Handling and Hiding Errors is Not the Same Thing
Article summary
Handling an Error Is Not the Same as Hiding It
Every Layer Should Add Information
Logging Is Not Enough
Return Values Can Bury Errors Too
Preserve the Stack Trace
User-Friendly and Developer-Friendly Are Different Goals
Be Careful with Retries and Fallbacks
Burying Errors at Boundaries
Make Errors Actionable
A Few Practical Habits
Errors Are Part of the Interface
Every software system fails. This is inevitable. But burying errors doesn’t make them go away.
As developers, we want to believe that our systems work as intended. So it’s easy to gloss over error handling. Until something actually goes wrong, it’s easier to log something vague and return success anyway, or convert a specific error into a generic one. Sometimes we retry until the original failure disappears from view. Sometimes we show the user a polite message but leave developers with no trail to follow.
Handling an Error Is Not the Same as Hiding It
One reason errors get buried is that we confuse handling an error with hiding it.
Handling an error is good. It means the code made an intentional decision about what to do next. Maybe it retries. Maybe it falls back to cached data. Maybe it gives the user a clear message. Maybe it translates a low-level failure into a domain-specific one.
Hiding an error is different. Hiding an error means losing information.
For example:
try<br>UploadInvoice(invoice);<br>catch (Exception)<br>return false;
While this prevents any unhandled exceptions from taking down the whole program, it also destroys almost everything useful about the failure.
That might be acceptable if the method is explicitly designed as a TryUploadInvoice API and the caller truly does not need more detail. But most of the time, this pattern just moves the problem somewhere else.
A better approach is to preserve the cause while adding context:
try<br>UploadInvoice(invoice);<br>catch (IOException ex)<br>throw new InvoiceUploadException(<br>$"Failed to upload invoice {invoice.Id}.",<br>ex);
This version still gives the caller a domain-specific error. But it does not erase the original exception. The next developer can still see the underlying cause.
Every Layer Should Add Information
Good error handling should work like a breadcrumb trail, even without a full stack trace. Each layer of the system knows something different.
The database driver knows that a connection was refused. The repository knows it was trying to load an order. The application service knows it was trying to create a shipment. The API knows which request triggered the operation. Each layer should contribute what it knows.
A weak error chain looks like this:
Operation failed.
A better one looks like this:
Failed to create shipment for order 12345.<br>Caused by: Failed to load order 12345.<br>Caused by: Database connection refused.
The second version is longer, but it is also much more useful. It tells a story. It gives the person investigating the issue a place to start.
That does not mean every error message needs to include every piece of data in the system. It means each layer should be careful not to discard the information it received from the layer below. Languages that throw Exceptions typically allow wrapping inner exceptions for this purpose.
Logging Is Not Enough
Another common way to bury errors is to assume that logging them is sufficient. Logging is important, but it’s not a substitute for preserving the error itself.
Consider this:
try<br>ProcessPayment(payment);<br>catch (PaymentGatewayException ex)<br>logger.LogError(ex, "Payment failed");<br>throw new CheckoutException("Checkout failed");
This is better than ignoring the exception. At least the original error was logged. But the thrown exception no longer contains the original cause. Anyone handling the CheckoutException later has lost the connection between checkout failure and payment gateway failure unless they can find the right log entry.
A better version preserves both:
try<br>ProcessPayment(payment);<br>catch (PaymentGatewayException ex)<br>logger.LogError(ex, "Payment failed for order {OrderId}", payment.OrderId);
throw new CheckoutException(<br>$"Checkout failed while processing payment for order {payment.OrderId}.",<br>ex);
Now the logs are useful, and the exception chain is useful. You might even decide that lower-level exceptions don’t need to be logged, and that a higher-level handler will be responsible for logging the exception along with all its inner exceptions. This can help cut down on log noise, but also makes it easier to accidently skip logging some exceptions.
Return Values Can Bury Errors Too
Exceptions are not the only place this problem shows up. Return values can bury errors just as easily.
For example:
public User? FindUser(string email)<br>try<br>return database.Users.SingleOrDefault(u => u.Email == email);<br>catch<br>return null;
This method uses null for two very different cases:
No user exists with that email (or more than one...