The Operation Result Pattern will save your .NET application from death by a thousand exceptions

The hidden cost of throwing exceptions has been heavily debated for years. Although the effect on an application’s performance will vary greatly based on where and how often exceptions are thrown, the evidence is undeniable that there is a significant impact.

Often exceptions can be avoided entirely by choosing an alternative method to test for failure before proceeding.

int.Parse() will generate an exception if the input string is invalid. This can be avoided by instead using int.TryParse().

Unfortunately, the .NET SDK is sprinkled with methods that generate exceptions without such alternatives.

For example, JsonSerializer.Deserialize() offers no JsonSerializer.TryDeserialize().

As such, many developers have become accustomed to letting exceptions bubble up, wrapping and rethrowing exceptions, or even just throwing their exceptions.

Instead of throwing exceptions, a better approach is to use the Operation Result Pattern. Ensuring that methods which can fail return a result eliminates the need the throw exceptions to communicate failure. This also eliminates the clutter of repetitive try-catch blocks that proliferate an application’s code.

Exceptions should be exceptional. Returning a result makes a clear distinction between failure within the application’s usual logic flow and a genuine exception that was unexpected.

A library such as LightResults can simplify the implementation of the result pattern by providing the necessary building blocks to replace exceptions with application errors.

Let’s take another look at JsonSerializer.Deserialize().

A typical implementation to avoid exceptions will simply try-catch for a JsonException.

using System.Text.Json;

public static class JsonHelper
{
    public static bool TryDeserialize<T>(string json, out T? result)
    {
        try
        {
            var obj = JsonSerializer.Deserialize<T>(json);
            if (obj is null)
            {
                result = default;
                return false;
            }

            result = obj;
            return true;
        }
        catch (JsonException)
        {
            result = default;
            return false;
        }
    }
}

Although this will stop the exception from bubbling up through the application, we now no longer have access to failures. We could argue that the JsonException can also be passed out, but this quickly gets convoluted.

using System.Text.Json;

public static class JsonHelper
{
    public static bool TryDeserialize<T>(string json, out T? result, out JsonException? jsonException)
    {
        try
        {
            var obj = JsonSerializer.Deserialize<T>(json);
            if (obj is null)
            {
                result = default;
                jsonException = null;
                return false;
            }

            result = obj;
            jsonException = null;
            return true;
        }
        catch (JsonException)
        {
            result = default;
            jsonException = null;
            return false;
        }
    }
}

Instead, let’s refactor the first TryDeserialize using LightResults.

using System.Text.Json;
using LightResults;

public static class JsonHelper
{
    public static Result<T> TryDeserialize<T>(string json)
    {
        try
        {
            var obj = JsonSerializer.Deserialize<T>(json);
            if (obj is null)
                return Result.Fail<T>("Could not deserialize the json string.");

            return Result.Ok<T>(obj);
        }
        catch (JsonException)
        {
            return Result.Fail<T>("An exception occured while attempting to deserialize the json string.");
        }
    }
}

Now the method’s logic flow is a lot more concise, and the different reasons why the method failed become easy to identify.

What about the exception? Well, LightResults allows any custom metadata to be attached to a result. So, we’ll simply attach the exception to the failure.

using System.Text.Json;
using LightResults;

public static class JsonHelper
{
    public static Result<T> TryDeserialize<T>(string json)
    {
        try
        {
            var obj = JsonSerializer.Deserialize<T>(json);
            if (obj is null)
                return Result.Fail<T>("Could not deserialize the json string.");

            return Result.Ok<T>(obj);
        }
        catch (JsonException ex)
        {
            return Result.Fail<T>("An exception occured while attempting to deserialize the json string.", ("Exception", ex));
        }
    }
}

There is the issue that now we’ve lost our ability to filter exceptions by type. Identifying errors by error message is not a recommended approach as that can quickly lead to mistakes if the error messages change. Instead, we can create our own derived errors based on the Error type.

using System.Text.Json;
using LightResults;

public sealed class JsonDeserializationError : Error
{
    public JsonDeserializationError()
        : base("Could not deserialize the json string.")
    {
    }

    public JsonDeserializationError(JsonException ex)
        : base("An exception occured while attempting to deserialize the json string.", ("Exception", ex))
    {
    }
} 

Then we can fail with that JsonDeserializationError instead or just a generic error.

using System.Text.Json;
using LightResults;

public static class JsonHelper
{
    public static Result<T> TryDeserialize<T>(string json)
    {
        try
        {
            var obj = JsonSerializer.Deserialize<T>(json);
            if (obj is null)
                return Result.Fail<T>(new JsonDeserializationError());

            return Result.Ok<T>(obj);
        }
        catch (JsonException ex)
        {
            return Result.Fail<T>(new JsonDeserializationError(ex));
        }
    }
}

Checking a result for a specific error type is also straightforward since results have a HasError method to do just that.

var result = JsonHelper.TryDeserialize<Person>(json);
if (result.IsFailed && result.HasError<JsonDeserializationError>())
{
    // We know the result failed and why!
}

There’s one last optimization we can do. Instead of creating all our errors, we can centralize them inside of an error factory for our application.

using System.Text.Json;
using LightResults;

public static class ApplicationError
{
    public static Result JsonDeserialization()
    {
        return Result.Fail(new JsonDeserializationError());
    }

    public static Result JsonDeserialization(JsonException ex)
    {
        return Result.Fail(new JsonDeserializationError(ex));
    }

    public static Result<T> JsonDeserialization<T>()
    {
        return Result.Fail<T>(new JsonDeserializationError());
    }

    public static Result<T> JsonDeserialization<T>(JsonException ex)
    {
        return Result.Fail<T>(new JsonDeserializationError(ex));
    }
}

Then call the error factory from our TryDeserialize method instead of using Result.Fail.

using System.Text.Json;
using LightResults;

public static class JsonHelper
{
    public static Result<T> TryDeserialize<T>(string json)
    {
        try
        {
            var obj = JsonSerializer.Deserialize<T>(json);
            if (obj is null)
                return ApplicationError.JsonDeserialization<T>();

            return Result.Ok<T>(obj);
        }
        catch (JsonException ex)
        {
            return ApplicationError.JsonDeserialization<T>(ex);
        }
    }
}

If you’re still try-catching everywhere, give LightResults a try. Once you start implementing the Operation Result Pattern in .NET, you’ll wonder how you ever did without it.

As a final note, I wrote the following benchmark in order to compare the performance impact between catching an exception, wrapping an exception with a result, and avoiding an exception entirely by using a failed result.

public class ResultBenchmarks
{
	[Params(1, 10, 100)]
    public int Iterations { get; set; }

	[Benchmark(Baseline = true)]
	public void ThrowingExceptions()
	{
		for (var iteration = 0; iteration < Iterations; iteration++)
			try
			{
				throw new FileNotFoundException();
			}
			catch (Exception)
			{
				// Ignore exception.
			}
	}

	[Benchmark]
	public void WrappingExceptionsWithResults()
	{
		for (var iteration = 0; iteration < Iterations; iteration++)
			try
			{
				throw new FileNotFoundException();
			}
			catch (Exception ex)
			{
				_ = Result.Fail("The file does not exist.", ("Exception", ex));
			}
	}

	[Benchmark]
	public void ReturningResults()
	{
		for (var iteration = 0; iteration < Iterations; iteration++)
			_ = Result.Fail("The file does not exist.");
	}
}
BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3155/23H2/2023Update/SunValley3)
13th Gen Intel Core i7-13700KF, 1 CPU, 24 logical and 16 physical cores
.NET SDK 8.0.200
  [Host]   : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2
  .NET 8.0 : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2
MethodIterationsMeanRatioAllocatedAlloc Ratio
ThrowingExceptions12,427.17 ns1.000240 B1.00
WrappingExceptionsWithResults12,631.09 ns1.085800 B3.33
ReturningResults112.61 ns0.005128 B0.53
MethodIterationsMeanRatioAllocatedAlloc Ratio
ThrowingExceptions1024,066.68 ns1.0002400 B1.00
WrappingExceptionsWithResults1026,499.82 ns1.1018000 B3.33
ReturningResults10132.79 ns0.0061280 B0.53
MethodIterationsMeanRatioAllocatedAlloc Ratio
ThrowingExceptions100239,495.52 ns1.00024000 B1.00
WrappingExceptionsWithResults100265,111.85 ns1.10780000 B3.33
ReturningResults1001,268.33 ns0.00512800 B0.53

Although there is a slight reduction in performance by wrapping the exception with a result, there is a phenomal gain in performance when returning a failed result while still maintaining the same level of information that could be provided by an exception.

Leave me a comment below or find me online and let me know how much you enjoyed eliminating exceptions from your application.

Leave a comment

Your email address will not be published. Required fields are marked *