Error handling: Exception or Result?
In this post, we’ll look at some practical examples of error handling. We will see whether it is better to use exceptions or the Result class to deal with errors.
Error handling: Exception or Result?
I mentioned previously in this blog and also in my Functional C# Pluralsight course that programmers tend to overuse and even abuse exceptions. It’s common to see situations where some code encounters a programmatic failure, throws an exception, and then another piece of code handles that exception some levels upper the call stack:
public async Task SendNotification(string endpointUrl)
{
try
{
HttpResponseMessage message = await _httpClient.PostAsync(endpointUrl);
string response = await message.Content.ReadAsStringAsync();
/* do something with the response */
}
catch (Exception ex)
{
throw new UnableToConnectToServerException(ex);
}
}
// Somewhere upper the call stack
try
{
await SendNotification(endpointUrl);
MarkNotificationAsSent();
}
catch (UnableToConnectToServerException)
{
MarkNotificationAsNotSent();
}
Such non-linear program flow can become a mess really quickly because it’s hard to trace all existing connections between throw
and catch
statements. No wonder such use of exceptions is often equated with goto
statements.
Thankfully, the mess can be avoided relatively easily. You just need to explicitly return values indicating success or failure of an operation instead of throwing exceptions. This would bring clarity to potentially error-prone code parts (admittedly, at the expense of brevity). I recommend using some version of the Result class to do that.
However, not all programmatic failures should be treated that way, and in this post, I want to talk about the other side of the spectrum. That is when programmers employ Result
in places where exceptions should be used.
But let’s step back for a second and recall types of failures from error handling perspective.
Different authors draw various sets of failure types but I personally think they all can be boiled down to the following two:
-
Errors we know how to deal with.
-
Errors we don’t know how to deal with.
The first one is exactly the type of errors the Result class is intended for. If you know how to process a failure, let alone expect that failure to happen, there’s no reason to use exceptions. It’s much better to be explicit about your intent and represent the result of the operation as a value so that you can handle it later the same way you handle other values:
public async Task<Result> SendNotification(string endpointUrl)
{
HttpResponseMessage message;
try
{
message = await _httpClient.PostAsync(endpointUrl);
}
catch (HttpRequestException ex)
{
return Result.Fail("Unable to send notification to: " + endpointUrl);
}
string response = await message.Content.ReadAsStringAsync();
/* do something with the response */
return Result.Ok();
}
// Somewhere upper the call stack
Result result = await SendNotification(endpointUrl);
if (result.IsSuccess)
{
MarkNotificationAsSent();
}
else
{
MarkNotificationAsNotSent();
}
Note that now there’s an explicit Result object that the client code can examine and use.
The other way to describe a failure you know how to deal with is using the phrase expected failure. You can’t work around a failure you didn’t expect, at least not in any meaningful way. Whenever your program can proceed with its execution flow after a failure has happened, the said failure becomes expected.
The code above knows what to do if the POST request fails, it’s an expected outcome. In this particular sample, the failure gets persisted to the database for later examination. And of course, there could be some sophisticated workaround in place of this logic, such as a re-try mechanism.
Let’s now look at another example. Let’s say we are registering a new customer in our system:
[HttpPost]
public HttpResponseMessage RegisterCustomer(string email)
{
var customer = new Customer(email);
Result saveResult = _repository.Save(customer);
if (saveResult.IsFailure)
return InternalServerError();
return Ok();
}
// Repository
public Result Save(Customer customer)
{
try
{
_session.SaveOrUpdate(customer);
return Result.Ok();
}
catch (ADOException)
{
return Result.Fail("Unable to save");
}
}
As you can see, this code sample also uses a Result instance. This instance shows whether or not saving the customer to the database succeeded. But is it the same kind of failure we saw in the previous example?
It is not.
Here, the RegisterCustomer
method doesn’t continue executing after a failure takes place. This particular operation has a fundamental assumption: the ability to save a customer to the database. No further action can be taken unless this assumption is met. And indeed, the code doesn’t do anything other than returning an Internal Server Error (500) response (which is perfectly justified because this error represents something we don’t normally expect to happen).
So what we have here is precisely the second type of failure: a failure we don’t know how to deal with. The fact that our code knows about this possibility doesn’t make any difference in this situation as it won’t be able to mitigate it. Even if we wrap it into a Result
instance, the best we can do is fail fast returning a 500 error to the client. It’s basically a dead-end, an exceptional situation for us.
And what programming feature helps us represent such exceptional situations? You got it, exceptions!
No need for the Result
class as we are not able to make use of it anyway. Exceptions already have the semantics we look for: they can interrupt the current operation and bubble up to some generic exception handler at the very top of our execution stack where they can be transformed into a 500 response. Remember, never return 500s intentionally!
Here’s how the code can look like after refactoring:
[HttpPost]
public HttpResponseMessage RegisterCustomer(string email)
{
var customer = new Customer(email);
_repository.Save(customer);
return Ok();
}
// Repository
public void Save(Customer customer)
{
try
{
_session.SaveOrUpdate(customer);
}
catch (ADOException ex)
{
Log(ex);
throw;
}
}
Or, even better:
[HttpPost]
public HttpResponseMessage RegisterCustomer(string email)
{
var customer = new Customer(email);
_repository.Save(customer);
return Ok();
}
// Repository
public void Save(Customer customer)
{
_session.SaveOrUpdate(customer);
}
Any exception thrown by the data access code will be propagated and then caught and logged at the top-most level of the application’s execution stack in the generic exception handler - exactly the behavior suitable for such kind of failures.
Alright, so here’s the takeaway from this article: don’t catch exceptions you don’t know how to deal with. At all. The exception semantics is exactly what you need in this case: let the error bubble up and stop the current operation entirely. A corollary to this rule is that there’s no benefit in wrapping such exceptions using the Result class (or any other return value for that matter).
Summary
It’s pretty easy to differentiate use cases for Result and exceptions. Whenever the failure is something you expect and know how to deal with - catch it at the lowest level possible and convert into a Result instance. If you don’t know how to deal with it - let it propagate and interrupt the current business operation. Don’t catch exceptions you don’t know what to do about.
Subscribe
Comments
comments powered by Disqus