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
{
UploadInvoice(invoice);
}
catch (Exception)
{
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
{
UploadInvoice(invoice);
}
catch (IOException ex)
{
throw new InvoiceUploadException(
$"Failed to upload invoice {invoice.Id}.",
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.
Caused by: Failed to load order 12345.
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
{
ProcessPayment(payment);
}
catch (PaymentGatewayException ex)
{
logger.LogError(ex, "Payment failed");
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
{
ProcessPayment(payment);
}
catch (PaymentGatewayException ex)
{
logger.LogError(ex, "Payment failed for order {OrderId}", payment.OrderId);
throw new CheckoutException(
$"Checkout failed while processing payment for order {payment.OrderId}.",
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)
{
try
{
return database.Users.SingleOrDefault(u => u.Email == email);
}
catch
{
return null;
}
}
This method uses null for two very different cases:
- No user exists with that email (or more than one exists!)
- Something went wrong while querying the database
Those are not the same outcome. Treating them as the same outcome forces the caller to guess, which causes misleading error messages.
Maybe the caller will show “user not found.” Maybe it will create a duplicate user, skip an important workflow, or fail later in a more confusing way.
A better design would distinguish these cases. Depending on the codebase, that might mean letting the exception propagate, returning a result type, or making the method contract more explicit.
For example:
public Result<User?, UserLookupError> FindUser(string email)
{
try
{
var user = database.Users.SingleOrDefault(u => u.Email == email);
return Result.Success<User?, UserLookupError>(user);
}
catch (DatabaseException ex)
{
return Result.Failure<User?, UserLookupError>(
new UserLookupError($"Failed to query user by email {email}.", ex));
}
}
Preserve the Stack Trace
In C#, there is a small but important detail that is easy to overlook. If you catch an exception and want to rethrow it, use:
throw;
not:
throw ex;
The first preserves the original stack trace. The second resets it from the current location, which can hide where the error actually started.
If you are wrapping an exception with a more specific exception, pass the original exception as the inner exception:
throw new OrderProcessingException(
$"Failed to process order {order.Id}.",
ex);
User-Friendly and Developer-Friendly Are Different Goals
There is a legitimate reason we sometimes hide error details: users do not need to see everything developers need to see. In some cases, users definitely must not see the same things developers see: things like stack traces, database details, SQL queries, or other internals that could give an attacker clues about your system.
But that doesn’t mean the information should disappear. It just means the system should present different levels of detail to different audiences.
Be Careful with Retries and Fallbacks
Retries and fallbacks are valuable tools, but they can also bury errors.
Suppose a service call fails, so the code falls back to cached data. That may be the right behavior. But if nobody records that the live call failed, the system can quietly run in a degraded state for a long time.
The same is true for retries. If an operation fails three times and succeeds on the fourth, the user may not need to know. But the team may still care. Repeated transient failures can signal a dependency problem, a scaling issue, or an approaching outage.
When code recovers from an error, consider whether the recovery itself should be observable. That could mean a structured log entry, a metric, a trace span, or some other signal.
A system that heals itself is good. A system that hides the fact that it had to heal itself is harder to trust.
Burying Errors at Boundaries
Errors are especially easy to bury at system boundaries.
APIs, message queues, background jobs, command-line tools, and service integrations often need to translate errors from one form into another. That translation is necessary, but it is also risky.
For example, an API might convert every exception into:
{
"error": "Internal server error"
}
That may be the right public response. But internally, the system should still retain the exception type, message, stack trace, request ID, user ID where appropriate, and any relevant domain identifiers.
Similarly, a background job should not simply mark work as failed with no reason attached. A failed job record that says “failed” is not much better than no record at all.
At boundaries, ask:
What information does the caller need?
What information does the operator need?
What information will the next developer need?
Those are different questions. Good systems answer all of them.
Make Errors Actionable
The best errors do more than say what failed. They suggest what to inspect next.
For example:
Failed to import customers.csv because row 42 has 9 columns, but the header has 8.
That message is immediately useful.
Compare it to:
CSV import failed.
The first points to the file, the row, and the mismatch. The second only says that someone has work to do.
When practical, include the information that would be most annoying to reconstruct later. Clear errors make a system feel understandable; vague errors make it feel hostile.
A Few Practical Habits
Here are some habits that help avoid buried errors:
- Catch specific exceptions when you can.
- Do not catch an exception unless you can add context, recover, translate it intentionally, or clean up.
- When wrapping an exception, preserve the original cause.
- Avoid using
null,false, or empty collections to represent failure unless the method contract is explicit about it. - Use structured logging for identifiers and important context.
- Keep user-facing messages safe and simple, but keep internal errors detailed.
- Review error messages the same way you review user-facing text or API design.
Errors Are Part of the Interface
It is tempting to think of errors as an unhappy path outside the real design of the system. But they are not. Errors are one of the main ways your system communicates with developers, operators, users, and other systems.
Every exception is a message from your software to the next person trying to understand what happened. Make sure the message survives the journey.