Stopping Using Exception Use Result Monad Instead
Introduction
Error handling is an inevitable part of software development. Traditionally, exceptions are the primary method for signaling that something has gone awry. However, the exception mechanism is fraught with issues: unpredictable control flow, performance bottlenecks, and challenges in debugging. Imagine a world where error handling is graceful, predictable, and efficient. Enter the Result Monad—a powerful alternative to exceptions that can streamline error handling, improve code maintainability, and make your programs more robust.
The Problem with Exceptions
Exceptions might seem like a convenient error-handling mechanism, but they introduce several issues. For starters, they disrupt the program's control flow, causing jumps that are difficult to manage and predict. Debugging such issues can quickly become a nightmare. Further, the overhead involved in generating and throwing exceptions can be significant, especially in performance-critical applications. Lastly, their implicit behavior makes the code harder to reason about. You might find yourself asking, "Where can this exception come from, and what can it contain?"
Understanding Result Monad
The Result Monad originated from functional programming and provides a structured way to handle computations that might fail. At its core, the Result Monad encapsulates a value that can either be a success or a failure, without throwing exceptions. This makes error handling explicit and predictable, enhancing code readability and maintainability. Typical use cases include API responses, file I/O operations, and database transactions. Essentially, any operation that can either succeed or fail can benefit from using a Result Monad.
Implementing Result Monad
To demonstrate the implementation, let’s use C# and the languageext.core
NuGet package:
Install the Package:
dotnet add package LanguageExt.Core
Business Services Implementation:
With Exceptions:
public class BusinessServiceWithException : IBusinessServiceWithException
{
public async Task<string> Get(string id)
{
await Task.CompletedTask;
if (string.Compare("HasValue", id, StringComparison.CurrentCultureIgnoreCase) == 0)
return id + " success";
var notFoundException = new NotFoundException(id);
throw notFoundException;
}
}
With Result Monad:
public class BusinessServiceWithResult : IBusinessServiceWithResult
{
public async Task<Result<string>> Get(string id)
{
await Task.CompletedTask;
if (string.Compare("HasValue", id, StringComparison.CurrentCultureIgnoreCase) == 0)
return id + " success";
var notFoundException = new NotFoundException(id);
return new Result<string>(notFoundException);
}
}
Controllers Implementation:
With Exceptions:
[ApiController]
[Route("[controller]")]
public class TestWithExceptionController(
ILogger<TestWithExceptionController> logger,
IBusinessServiceWithException businessServiceWithException)
: ControllerBase
{
[HttpGet("{id}")]
public async Task<ActionResult<string>> Get(string id)
{
var result = await businessServiceWithException.Get(id);
return Ok(result);
}
}
The exceptions are tranformed to the correct server response by a middleware:
public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next));
private readonly ILogger<ExceptionHandlingMiddleware> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch(NotFoundException ex)
{
//_logger.LogError(ex, "Entity not found.");
await HandleNotFoundExceptionAsync(context, ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception has occurred.");
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleNotFoundExceptionAsync(HttpContext context, NotFoundException notFoundException)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
return context.Response.WriteAsJsonAsync(new { message = notFoundException.Message });
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
// You can customize the response as needed.
// Here, we're sending a simple JSON response.
var response = new { message = "Internal Server Error" };
return context.Response.WriteAsJsonAsync(response);
}
}
With Result Monad:
[ApiController]
[Route("[controller]")]
public class TestWithResultController(
ILogger<TestWithResultController> logger,
IBusinessServiceWithResult businessServiceWithResult)
: ControllerBase
{
[HttpGet("{id}")]
public async Task<ActionResult<string>> Get(string id)
{
var result = await businessServiceWithResult.Get(id);
return result.Match<ActionResult<string>>(success => Ok(success),
failure =>
{
return failure switch
{
NotFoundException notFoundException => StatusCode((int)HttpStatusCode.NotFound,
new { message = notFoundException.Message }),
_ => StatusCode((int)HttpStatusCode.InternalServerError,
new { message = "Internal Server Error" })
};
}
);
}
}
Benchmark results
Let's further solidify this with some benchmarking to quantify the performance differences between the two approaches:
public class Benchmarks
{
private readonly HttpClient _httpClient = new HttpClient();
private const string TestWithResultUrl = "http://localhost:56303/TestWithResult/NoMatchingValue";
private const string TestWithExceptionUrl = "http://localhost:56303/TestWithException/NoMatchingValue";
[GlobalSetup]
public void Setup() {}
[Benchmark]
public async Task TestWithResultController()
{
var response = await _httpClient.GetAsync(TestWithResultUrl);
await response.Content.ReadAsStringAsync();
}
[Benchmark]
public async Task TestWithExceptionController()
{
var response = await _httpClient.GetAsync(TestWithExceptionUrl);
await response.Content.ReadAsStringAsync();
}
}
First start the WepApi:
Here are the benchmark results:
In this case TestWithExceptionController.Get is x 1.6 slower than TestWithResultController.Get.
Comparative Analysis: Exceptions vs. Result Monad
Let's break down the differences:
- Readability: The Result Monad makes error handling explicit, making code more readable. Exceptions, on the other hand, can create hidden paths in the code flow that are harder to follow.
- Performance: Avoiding exceptions eliminates the overhead associated with generating stack traces. This can be particularly beneficial in high-performance applications.
- Debugging: With Result Monads, errors are handled explicitly, making it easier to pinpoint where and why a failure occurred, thus simplifying debugging.
Conclusion
By moving from exceptions to the Result Monad, developers can achieve better code readability, improved performance, and more predictable error handling. The Result Monad reduces the cognitive load required to understand and manage failure states, making for cleaner, more robust code. So next time you're faced with the choice, consider opting for the Result Monad and experience the benefits firsthand.
Have a goat day 🐐
Github : https://github.com/goatreview/ExceptionVsResult
Associated resources :
https://goatreview.com/mediatr-quickly-test-handlers-with-unit-tests/
https://goatreview.com/improving-error-handling-result-pattern-mediatr/