Software Engineering5 min read

Result Pattern: control flow done right

How to use the Result Pattern to make expected failures explicit, avoid exceptions as control flow, and improve readability in .NET applications.

Result Pattern: control flow done right

Software Engineering

dotnet, architecture, result-pattern, best-practices

en

When implementing any use case, there is a “happy path” where everything happens as expected: from receiving the input, connecting to the database, and integrating with other systems, all the way to successfully completing the use case.

For example, in a simple method whose goal is to convert a string into an integer, we could have the following code:

public int ConvertToInt(string value)
{
    return Int32.Parse(value);
}

Here, we are assuming that the received value will always be valid and will not cause an error. However, alternative flows also exist. If the value cannot be converted to an integer, the application should anticipate that situation and handle it in the best possible way.

public int ConvertToInt(string value)
{
    try
    {
        return Int32.Parse(value);
    }
    catch
    {
        throw new Exception("Value is invalid");
    }
}

Or even like this:

public int ConvertToInt(string value)
{
    if (Int32.TryParse(value, out int result))
    {
        return result;
    }

    throw new Exception("Value is invalid");
}

Although it may seem convenient to use exceptions for control flow, including in .NET 8 with a global exception handler instead of middleware, this is not control flow.

A global exception handler will usually end the execution because it indicates that an unrecoverable error has occurred. Control flow means you have the opportunity to inspect the error and decide how to proceed in a way that lets the code keep flowing naturally. Exceptions, by definition, interrupt the flow and therefore should not be used as control flow.

Result Pattern

Using the Result Pattern offers several significant advantages over using exceptions for control flow, especially when it comes to readability, performance, and code maintenance.

Code readability: the Result Pattern makes it clear that a function can fail and what kinds of failures can happen, without requiring you to read a list of possible exceptions. This makes the code easier to understand.

Performance: it avoids the cost of building and handling exception stacks, resulting in better performance, especially in scenarios where failures are expected as part of the normal program flow.

Maintainability: it improves maintainability by making the error flow explicit and reducing the need to document and track exceptions that may be thrown.

Inappropriate error handling: exceptions should be reserved for truly exceptional and unexpected situations, not for normal control flow. The Result Pattern encourages this practice, promoting a more robust and intuitive software design.

Implementation

First, let’s create our error structure. It has only two parameters, but we can extend it with more information depending on the need. Our struct implements the Failure method as a builder for creating an error object.

public readonly record struct Error
{
    private Error(string code, string description)
    {
        Code = code;
        Description = description;
    }

    public string Code { get; }

    public string Description { get; }

    public static Error Failure(
        string code = "General.Failure",
        string description = "A failure has occurred.") =>
        new(code, description);
};

Below we have our Result struct. It is a generic type where we receive either the result of our operation or one or more errors from our exception flow.

public readonly record struct Result<TValue>
{
    private readonly TValue? _value = default;
    private readonly List<Error>? _errors = null;

    public Result()
    {
        throw new InvalidOperationException();
    }

    public Result(Error error)
    {
        _errors = [error];
    }

    public Result(List<Error> errors)
    {
        if (errors is null)
        {
            throw new ArgumentNullException(nameof(errors));
        }

        if (errors is null || errors.Count == 0)
        {
            throw new ArgumentException("Cannot create an Result<TValue> from an empty collection of errors.", nameof(errors));
        }

        _errors = errors;
    }

    public Result(TValue value)
    {
        if (value is null)
        {
            throw new ArgumentNullException(nameof(value));
        }

        _value = value;
    }

    public bool IsError => _errors is not null;

    public TValue Value
    {
        get
        {
            if (IsError)
            {
                throw new InvalidOperationException("The Value property cannot be accessed when errors have been recorded. Check IsError before accessing Value.");
            }

            return _value;
        }
    }
}

Now we can see the implementation of our method that converts a string into an integer using the Result Pattern.

public Result<int> ConvertToInt(string value)
{
    if (Int32.TryParse(value, out int result))
    {
        return new Result<int>(result);
    }

    return new Result<int>(Error.Failure());
}

We can improve this solution by making the constructors private and using implicit operator to make the code more readable.

public static implicit operator Result<TValue>(TValue value)
{
    return new Result<TValue>(value);
}

public static implicit operator Result<TValue>(Error error)
{
    return new Result<TValue>(error);
}

public static implicit operator Result<TValue>(List<Error> errors)
{
    return new Result<TValue>(errors);
}

Then our implementation will look like this:

public Result<int> ConvertToInt(string value)
{
    if (Int32.TryParse(value, out int result))
    {
        return result;
    }

    return Error.Failure();
}

Conclusion

Unlike the Result Pattern, exceptions are designed to handle unexpected conditions, not to control the flow of your application. Using them for that purpose distorts the original purpose of exceptions and can lead to a less intuitive software design.

The Result Pattern also does not require complex methods or abstractions, making its implementation quite simple.

There are some NuGet packages that implement the Result Pattern, such as FluentResults and ErrorOr, which can be easily configured and used in your project. Alternatively, you can implement your own Result Pattern. This pattern is cross-cutting, which means it can be applied from the innermost layers to the outermost layers of your application.

Repository: https://github.com/FcoJunior/ResultPattern

#dotnet#architecture#result-pattern#best-practices
Francisco Junior

Hi, I am Francisco Junior.

I am a Software Engineer and Tech Lead.

I work daily building distributed financial platforms. On this blog I share lessons about architecture, engineering, cloud, platform, technical leadership, and real decisions from software development.